Plugins
Analytics

Options

Every option of analytics(), with its type, its default and what it does.

analytics() takes one object, AnalyticsPluginOptions (also exported as PluginOptions). Every path below is checked when the plugin builds the Payload config: a value of the wrong shape, a key no table lists at any level, an id that names no adapter, or a combination the plugin cannot honor fails the boot with a message naming the path. An option an earlier release took and this one no longer does fails the boot too, naming its replacement (Removed options).

The native engine and each provider take options of their own, as arguments of native(), plausible() and the rest. Each of them takes label, the source's name in the admin: a string, a map by admin language, or a function per reader (A source's name); and withoutConsent, which beats capture.withoutConsent for its own slot (Without consent).

payload.config.ts
import { analytics } from '@10x-media/analytics'
import { native } from '@10x-media/analytics/adapters/native'

export const plugin = analytics({
  adapters: [native()],
  capture: { gate: 'all' },
  collections: { pages: { path: (doc) => (typeof doc.slug === 'string' ? `/${doc.slug}` : null) } },
  revenue: { currency: 'EUR' },
  trustedPlatform: 'vercel',
})

Top level

OptionTypeDefaultWhat it does
adaptersAnalyticsAdapter[]requiredThe config adapters. At least one, unless disabled is set, and no two with the same id.
defaultAdapterstringthe first adapterThe source a read uses when it names none. Must be an id in adapters. On a scoped install a tenant's default can differ: a provider document flagged Default for this site, then defaultSource, come first (A tenant's default source).
platformAdapterstringnoneThe id of one config adapter shared by every scope: the platform's own analytics. It fills the global capture slot unless capture.slots.global says otherwise. Must be an id in adapters.
disabledbooleanfalseSwitches the plugin off and keeps its database schema: below.
translationsTranslationsOptionnonePer-language overrides of the admin strings, keyed by the typed keys of @10x-media/analytics/i18n (Translations). A key that is not one of the plugin's own fails the boot.
reportingTimezonestring | TimezoneResolver'UTC'The zone days are cut in (Reporting timezone). A string Intl.DateTimeFormat does not accept as a time zone fails the boot. A resolver answers per scope, also at ingest, and null means UTC.
trustedProxyHopsnumbernone: the leftmost x-forwarded-for entryHow many proxies in front of the app to trust, counted from the right of x-forwarded-for, when reading a visitor's address; also lets native ingest read x-forwarded-host (Trusted proxies). A non-negative whole number. Cannot be set with trustedPlatform.
trustedPlatform'vercel' | 'cloudflare'none: no platform header is readThe platform whose edge headers carry the visitor's address and location (Geo). Cannot be set with trustedProxyHops.
sdksAnalyticsSdks{}Optional packages handed in, so a standalone or serverless build ships them. A production boot warns about one it would load by name.
sdks.ga4{ BetaAnalyticsDataClient } from @google-analytics/datanoneThe GA4 Data API client for every GA4 source (GA4).
sdks.maxmind{ open } from maxmindnoneThe MaxMind reader for native({ geoDbPath }) and maxmindResolver (Geo).
routes{ base? }Where the endpoints answer.
routes.base`/${string}`'/_x'The path every endpoint the plugin registers hangs off, under Payload's routes.api (Where the endpoints answer).
hooks{ afterRead? }Hooks into what the plugin does.
hooks.afterReadAfterReadHooknoneRuns once for every read answered to a reader, after it is cached, with { adapterId, query, result, from, durationMs, payload }; return a result to answer that one read differently (Observing reads).

scope

Tenancy: how a request maps to its scope. Unset, the install is one scope. Multi-tenancy has the recipe.

OptionTypeDefaultWhat it does
scope.resolve({ req, callSite }) => string | nullrequired in scopeWhich scope a request is for. callSite is 'capture' (a visitor's hit, asked without the user, cookies or authorization header) or 'admin'. null is the whole install; answering '*' is refused.
scope.member({ req, scope }) => booleanrequired with resolveWhether the request's user belongs to the scope it selected. tenantMembership() reads @payloadcms/plugin-multi-tenant's tenants array; () => true opts out.
scope.list({ payload }) => string[]noneThe tenant scopes the sync task runs over, besides the install-wide pass.
scope.label({ req, scope }) => string | undefinedthe scope idWhat the view's caption and Site select call a scope.
sources{ [adapterId]: { tenants? } }{}: every config adapter is 'staff'Per shared config adapter, which of a tenant's own users read that tenant through it (Letting tenants read a shared source). Each key must be an id in adapters.
sources.<id>.tenants'staff' | 'tenant' | 'fallback' | ({ req, scope, adapterId, ownSources }) => boolean'staff''staff': only readers access.crossScope.unscopedSource admits. 'tenant': the scope's members too, narrowed to their tenant. 'fallback': its members until they read a source of their own. A function: its members when it answers true; a throw denies. Anything but 'staff' needs an adapter with capabilities.scopedQueries, or the boot fails.
defaultSource'tenantFirst' | 'platformFirst' | ({ req, scope, sources }) => string | undefinednone: defaultAdapter, else the first config adapter, else the first source the reader may readA tenant scope's default source when none of its provider documents is flagged the default. 'tenantFirst' picks the reader's first source from the tenant's own documents, 'platformFirst' the platformAdapter, a function an id from sources; one the reader cannot read, or a throw, keeps the default.

Each may also return a promise. These options only mean something on a scoped install, so each fails the boot without scope.resolve: scope.list, scope.label, scope.member, sources, defaultSource, access.crossScope, an adapter id in capture.slots.tenant, capture.paths.tenant, a collections.<slug>.scopeField other than false, and goals.collection.scopeField or providers.collection.scopeField when set at all.

access

OptionTypeDefaultWhat it does
access.read({ req }) => booleanisAdminUser: users of the admin collection (admin.user)Gates every read: the read endpoints, the widgets, the display fields and the read helpers. It never widens which scope a user reads.
access.view({ req }) => booleanaccess.readGates the analytics view and the exclusion endpoint. The view shows goal and site names only to a reader access.read admits as well.
access.crossScopeCrossScopeAccess | { named?, aggregate?, unscopedSource? }scoped: every gate denies; unscoped: every gate is isAdminUser and the option cannot be setReads past the request's own scope. A function grants all three gates; the object form decides each one, and a gate it leaves out denies.
access.crossScope.namedCrossScopeAccessas aboveReading a scope the request does not resolve to, by naming it; also who manages another tenant's provider, goal and sync documents.
access.crossScope.aggregateCrossScopeAccessas aboveReading every scope at once: scope=*, the view's All sites, and a scoped request that resolves no tenant.
access.crossScope.unscopedSourceCrossScopeAccessas aboveReading a tenant through a config adapter rather than its own provider documents (Who sees what). sources can let a tenant's own members in as well.

CrossScopeAccess is ({ req, scope?, adapterId? }) => boolean, told what is asked where the read knows it: named the scope it names, aggregate '*' (or null for a scoped request that resolves no tenant), unscopedSource the tenant and the adapter. A gate may run once per scope and adapter in a request (What a gate is told). Each function may also return a promise.

capture

What the browser tracker captures and where it sends it (Capture).

OptionTypeDefaultWhat it does
capture.gate'all' | 'off' | CaptureSlot[] | ({ slot, adapterId, kind }) => booleannone: required whenever a capture slot can fillWhich slots wait for your consent manager (Consent). A function answers per slot, true for one that waits; any answer other than false, or a throw, holds the slot.
capture.withoutConsent'cookieless' | 'off' | { pending?, refused? } | ({ slot, adapterId, kind, phase }) => 'cookieless' | 'off''cookieless'What a slot capture.gate holds does before an answer and after a refusal (Measuring without consent). 'cookieless' counts without storing anything on the device, at the level each adapter declares (GA4's is 'off'); 'off' sends nothing until consent, as 1.0.0-beta.14 did. A function answers per slot and phase on the server; a throw or any other answer is 'off', logged once. An adapter's own withoutConsent beats it. Unset, the boot logs one line naming the default while a held slot can count without consent.
capture.withoutConsent.pending, capture.withoutConsent.refused'cookieless' | 'off''cookieless'The level for one phase: no answer yet, or refused. A phase left out is 'cookieless'; any other key fails the boot.
capture.slots{ global?, tenant? }Which adapter fills each capture slot.
capture.slots.globalstring | falseplatformAdapter, else the only config adapterA config adapter's id, which must be in adapters, or false to leave the slot empty.
capture.slots.tenantstring | falsethe scope's default source when it captures, else its first source that capturesAn adapter id, a runtime provider's included, resolved per request, or false.
capture.paths{ global?, tenant? }Where each slot's snippet points, for a slot relayed by a route handler of your own (A route handler of your own).
capture.paths.global, capture.paths.tenantstringthe runtime proxy, <routes.api><routes.base>/p/<slot>The path a captureRoute handler from @10x-media/analytics/next is mounted at, without the Next basePath.
capture.autoCaptureAutoCaptureOptionsThe tracker's listeners (Auto-capture).
capture.autoCapture.goalAttributebooleantruedata-analytics-goal and data-analytics-event on click and form submit.
capture.autoCapture.outboundLinksbooleantrueoutbound_link for a link to another origin.
capture.autoCapture.fileDownloadsbooleantruefile_download for a link to a known file type.
capture.autoCapture.scrollDepthbooleanfalseScroll depth, and scroll_depth events at 25, 50, 75 and 100 percent.
capture.autoCapture.querybooleantrueThe page's query string on each pageview, which campaign attribution reads.
capture.autoCapture.itemsfalse | { impressions?, clicks?, pages? }each trueContent item capture. Off whatever this says while no collection is bound with item: true.
capture.autoCapture.items.impressionsbooleantrueViews of marked elements.
capture.autoCapture.items.clicksbooleantrueitem_click for the first click inside a marked element.
capture.autoCapture.items.pagesbooleantrueAn item counted per page it appeared on, beside its total.
capture.skip{ automation?, previews? }Pages left uncounted (Pages that are not counted).
capture.skip.automationbooleantrueLeaves out a browser that reports navigator.webdriver.
capture.skip.previewsbooleantrueLeaves out draft mode pages and Payload's live preview.
capture.exclusionboolean | { param?, sites? }falseThe signed link staff open to leave their own browser out of the numbers (Excluding your own visits). true turns it on.
capture.exclusion.paramstring'analytics_exclude'The query parameter the tracker reads: letters, digits, ., _, ~ and -, at most 64.
capture.exclusion.sitesstring[] | ({ req, scope }) => string[]noneWhere each scope's sites run, so the view hands out whole links. A list is checked at boot; a function's answer per request.
capture.allowedOriginsstring[][]Other sites whose pages may send to the ingest endpoint and the capture proxy, each one exact http(s) origin (Beacons from other sites).
capture.proxy{ timeoutMs?, maxBodyBytes? }Limits of the runtime capture proxy.
capture.proxy.timeoutMsnumber10000Milliseconds to wait for the vendor's response headers. A whole number of at least 1.
capture.proxy.maxBodyBytesnumber1048576 (1 MiB)The largest request body forwarded; a larger one answers 413. A whole number of at least 1.

goals

The goals an install counts conversions against: a list, which is goals.defaults with the collection off, or the object below.

OptionTypeDefaultWhat it does
goalsGoal[] | { defaults?, collection?, purchase? }noneConfig goals, and the collection editors manage goals in.
goals.defaultsGoal[][]The config goals: each { slug, name, match, value?, currency?, browser? }. Slugs are kebab-case and unique; a path pattern holds at most 32 segments and 4 **.
goals.purchasebooleantrueThe built-in latent purchase goal a sale books against; false removes it.
goals.collectionboolean | { slug?, scopeField?, fields?, overrides?, access? }falseThe goals collection (Let editors manage goals). true uses the defaults.
goals.collection.slugstring'analytics-goals'The collection's slug.
goals.collection.scopeFieldstring'scope'The top-level field matched against the scope. 'scope' registers a hidden text field; another name registers none and must exist on the collection. Scoped installs only.
goals.collection.fields{ [field]: ({ field }) => Field }noneReplaces one default field at a time, before overrides (Replacing a field).
goals.collection.fields.name, goals.collection.fields.slug, goals.collection.fields.enabled, goals.collection.fields.browser, goals.collection.fields.match, goals.collection.fields.value, goals.collection.fields.currency({ field }) => FieldnoneOne field each. A slug replacement must keep a field named slug inside what it returns.
goals.collection.overrides(collection) => CollectionConfignoneReshapes the collection last. It may not rename it or turn off its timestamps.
goals.collection.accessPartial<CollectionConfig['access']>admin collection only, narrowed to the scopes the user belongs toReplaces the default for each operation it sets.

revenue

Money as a capability (Revenue). false or unset measures none; true turns it on with the defaults; an object turns it on.

OptionTypeDefaultWhat it does
revenueboolean | AnalyticsRevenueOptionsfalseOff, the revenue metric is gone from every source and every amount field is hidden; stored amounts are kept.
revenue.currencystring | ({ req, scope }) => string | undefinednone: revenue is a plain numberThe ISO 4217 reporting currency, or one per scope. A string must be three capital letters.
revenue.convertCurrencyConverternoneConverts an amount sent in another currency at ingest; without it such an amount stays out of revenue.
revenue.dimensionbooleantrue without convert, false with itDeclares the currency dimension: gross per currency.
revenue.browserAmounts'none' | 'fixed' | 'trust''fixed'How far an amount a browser sends is believed (Amounts from the browser).
revenue.orderSignature'optional' | 'required''optional'Whether a page's order id needs the server's signature (Recorded in the browser and by the webhook).

collections

Bindings, keyed by collection slug: how a document's display fields find its numbers. A binding needs path, pathField or item: true.

OptionTypeDefaultWhat it does
collections{ [slug]?: AnalyticsBinding }{}One binding per collection.
collections.<slug>.path(doc, { req, locale? }) => string | nullnoneThe URL path a document's numbers were recorded under.
collections.<slug>.pathFieldstringnoneA field holding the path, read when path is unset or answers nothing. Its value is used as written, so it has to start with /; a dotted name reads a nested field.
collections.<slug>.hostnamestring | (doc, context) => string | nullnone: every hostnameNarrows a document's reads to one hostname.
collections.<slug>.itembooleanfalseBinds the collection's documents as content items.
collections.<slug>.scopeFieldstring | false'tenant' on a scoped install, false otherwiseThe top-level field a document's own scope is read from, so its numbers are its tenant's (Whose numbers a document shows). A named field must exist on the collection.

providers

Provider documents: vendor sources configured from the admin, per scope. collection and resolve cannot both be set, and hosts needs one of them.

OptionTypeDefaultWhat it does
providers.collectionboolean | ProvidersCollectionOptionsfalseThe analytics-providers collection. true uses the defaults.
providers.collection.slugstring'analytics-providers'The collection's slug.
providers.collection.scopeFieldstring'scope'The field matched against the scope, such as a tenant plugin's 'tenant'. Scoped installs only.
providers.collection.fields{ name?, provider?, enabled?, isDefault?, withoutConsent? }noneReplaces one default field at a time, before overrides (Replacing a field).
providers.collection.fields.name, providers.collection.fields.provider, providers.collection.fields.enabled({ field }) => FieldnoneOne field each.
providers.collection.fields.isDefault({ field }) => FieldnoneThe Default for this site checkbox, which only a scoped install's collection has.
providers.collection.fields.withoutConsent({ field }) => FieldnoneThe Without consent select of a vendor document, which follows capture.withoutConsent when empty (What one document holds).
providers.collection.typesProviderId[]every type, 'native' only while adapters holds native()The provider types a document may have; the first is the Provider select's default, and the select lists them in a fixed order (Which provider types a document may have). An unknown id fails the boot, and so does a list that leaves no type the install serves.
providers.collection.overrides(collection) => CollectionConfignoneReshapes the collection last. It may not rename it or turn off its timestamps.
providers.collection.accessPartial<CollectionConfig['access']>admin collection only, narrowed to the scopes the user belongs toReplaces the default for each operation it sets.
providers.collection.encryption{ keys? }How stored credentials are sealed.
providers.collection.encryption.keysKeysConfig from @10x-media/fieldsthe fields plugin's encrypted.keys, else a ring derived from the Payload secretA dedicated key ring for the credentials.
providers.collection.connectionAccessFieldAccess | { create?, update? }creators set it; changing it takes access.crossScope.named on a scoped install, the collection's update access otherwiseWho may set or change where a document connects (Who may change a connection).
providers.collection.connectionAccess.create, providers.collection.connectionAccess.updateFieldAccessas aboveOne operation each.
providers.collection.defaultAccessFieldAccess | { create?, update? }connectionAccess, else its defaultWho may set or change a document's Default for this site checkbox on a scoped install; a denied value is dropped (A tenant's default source).
providers.collection.defaultAccess.create, providers.collection.defaultAccess.updateFieldAccessas aboveOne operation each.
providers.collection.sourceAccessAccessthe collection's read accessWhich of a scope's documents a reader reads analytics through (Who reads through a document).
providers.resolve({ payload, req?, scope, fetch, hosts, sdks }) => AnalyticsAdapter[]noneReplaces the collection: a scope's adapters from a store of your own (Keeping the settings somewhere else).
providers.hosts{ allow?, allowPrivateNetwork? }Where provider documents may connect (Where a document may connect).
providers.hosts.allowstring[][]: each vendor's own cloud onlyFurther origins a document's host may name; https://*.example.com covers every subdomain.
providers.hosts.allowPrivateNetworkbooleanfalseLets document adapters reach private addresses, over http too.

view

The analytics view. false registers neither the view nor its nav link; the read endpoints stay.

OptionTypeDefaultWhat it does
viewfalse | AnalyticsViewOptionsonThe view and its nav link.
view.path`/${string}`'/analytics'The admin path, under routes.admin.
view.defaultRangeTimeframePreset'last30days'The window the view opens on; allTime opens on last30days.
view.defaultMetricMetricKey'pageviews'The metric the cards and the trend open on.
view.navLabelstring | Record<string, string>the translated viewNavLabelThe nav link and breadcrumb label; a map is keyed by admin language.
view.navLinkbooleantruefalse registers the view without its link in the admin nav, for a host that links it from navigation of its own.
view.ComponentAnalyticsComponentnoneA client component in place of the view's body (@beta, Customizing).

widgets

The dashboard widgets (@beta, since Payload's Modular Dashboard is experimental). false registers none.

OptionTypeDefaultWhat it does
widgetsboolean | AnalyticsWidgetsOptionstrueThe widget layer.
widgets.disabledstring[][]Widget slugs left out, built-in or your own. A slug that names no widget of the install fails the boot.
widgets.registerCustomWidgetDef[][]Widgets of your own (Custom widgets), each with a slug, a component path and a label. Slugs may not start with analytics-.
widgets.localizeTextbooleanfalseMarks each widget's Title field localized.
widgets.comparisonbooleantruefalse skips the previous-window read and removes the compare checkboxes.
widgets.overrides{ [slug in BuiltInWidgetSlug]?: AnalyticsWidgetOverride }{}Changes a built-in widget by slug (Customizing).
widgets.overrides.<slug>.ComponentAnalyticsComponentnoneA server component in place of the widget's.
widgets.overrides.<slug>.labela string, a map by language, or a label functionnoneThe widget's name in the widget list.
widgets.overrides.<slug>.fields({ defaultFields }) => Field[]noneThe widget's settings form.

countryFlags

OptionTypeDefaultWhat it does
countryFlagsboolean | { Component }trueFlags beside country names in the view, the widgets and the document panel. false draws none; { Component } draws your own (@beta, Country flags). The flag route is mounted only for true.
countryFlags.ComponentAnalyticsComponentA client component receiving { code }.

cache

How reads are cached and how much they ask of their sources (Caching).

OptionTypeDefaultWhat it does
cache.ttl{ aggregate?, realtime? }Seconds an answer stays fresh, each a whole number of 0 or more. A native read reaching into today stays fresh 10 seconds at most whatever they say (TTLs).
cache.ttl.aggregatenumbereach adapter's recommendedTtl.aggregateFor historical reads.
cache.ttl.realtimenumbereach adapter's recommendedTtl.realtimeFor live counts and reads of days still counting.
cache.timeoutMsnumber15000How long a read with nothing cached waits for its source, every retry included. A whole number of at least 1.
cache.concurrency{ global?, perSource?, perScope? }How much reads ask of their sources at once (Reaching the sources).
cache.concurrency.globalnumber16Requests in flight across every vendor source of one instance.
cache.concurrency.perSourcenumber4Requests in flight per vendor source that declares no rateLimit.maxConcurrent.
cache.concurrency.perScopenumber8Reads with nothing cached one scope may have waiting; past it the query endpoint answers 429.
cache.warmboolean | { windowHours?, maxReads? }falseTurns on the analytics-warm task, which reads recent reads again before they expire (Warming).
cache.warm.windowHoursnumber24A read asked for this recently counts as recent.
cache.warm.maxReadsnumber200The most reads one run refreshes.
cache.storeCacheStorethe analytics-cache collectionA store of your own, such as Redis (Your own store); the collection is then not registered.
cache.collection{ slug?, overrides? }The default store's collection. Refused beside store.
cache.collection.slugstring'analytics-cache'Its slug.
cache.collection.overrides(collection) => CollectionConfignoneReshapes it, within what the plugin reads and writes.

sync

The opt-in sync tier: the analytics-sync task copies each vendor source's daily metrics into a collection. An object turns it on.

OptionTypeDefaultWhat it does
syncboolean | AnalyticsSyncOptionsfalseThe sync tier.
sync.lookbackDaysnumber3Days each run reads back, today counted. A whole number of at least 1.
sync.adaptersstring[]every vendor sourceThe sources it copies: a config adapter's id, or a provider type for every document of that type.
sync.collection{ slug?, overrides?, hidden? }The collection it writes.
sync.collection.slugstring'analytics-daily'Its slug.
sync.collection.overrides(collection) => CollectionConfignoneReshapes it, within what the plugin writes.
sync.collection.hiddenbooleantrueKeeps it out of the admin nav.

jobs

The plugin's tasks on Payload's jobs queue: analytics-maintain with native(), analytics-sync with sync, analytics-warm with cache.warm (The jobs option).

OptionTypeDefaultWhat it does
jobsfalse | AnalyticsJobsOptionsevery registered task on 'default', on its default schedulefalse registers no task: native's upkeep then happens on requests, and sync or cache.warm beside it fails the boot.
jobs.queuestring'default'The queue every task is scheduled on unless its own entry names another.
jobs.tasks{ maintain?, sync?, warm? }One entry per task; an entry for a task the install does not register fails the boot.
jobs.tasks.maintain{ queue?, cron? }analytics-maintain.
jobs.tasks.maintain.queuestringjobs.queueIts queue.
jobs.tasks.maintain.cronstring | false'*/15 * * * *'Its schedule: five fields, six with seconds first, or a nickname such as '@hourly'. false registers it unscheduled.
jobs.tasks.sync{ queue?, cron? }analytics-sync.
jobs.tasks.sync.queuestringjobs.queueIts queue.
jobs.tasks.sync.cronstring | false'0 */6 * * *'Its schedule.
jobs.tasks.warm{ queue?, cron? }analytics-warm.
jobs.tasks.warm.queuestringjobs.queueIts queue.
jobs.tasks.warm.cronstring | false'*/30 * * * *'Its schedule.
jobs.state{ slug?, overrides? }On an install without native(), the collection sync and warming keep their leases and heartbeats in. Refused beside native() and on an install running neither.
jobs.state.slugstring'analytics-state'Its slug.
jobs.state.overrides(collection) => CollectionConfignoneReshapes it, within what the plugin reads and writes.

Switching it off

disabled: true switches the plugin off and keeps its database schema:

analytics({
  disabled: process.env.ANALYTICS_OFF === '1',
  adapters: [native()],
  capture: { gate: 'off' },
})

A switched-off install registers the same collections with the same fields and indexes (providers, goals, sync, cache, the native engine's three when native() is listed, and analytics-state for sync or warming) and the same task names in Payload's jobs, so a development push or a generated migration drops nothing, and every row is still there when the plugin comes back. An adapter's own collections are kept by its registerSchema, which every built-in adapter has; a custom adapter that adds collections in register without one loses them while the plugin is off, and a push or a migration then drops them.

Nothing that runs is registered: no endpoint, view or widget. Each task does nothing, and its schedule, kept so Payload keeps the payload-jobs-stats global it adds for scheduled tasks, has a hook that never queues a run. What belongs to the collections stays as it is: the providers and goals collections keep their access and their hooks, so editors still manage them in the admin. Payload's job controls stay restricted to the admin collection as when the plugin runs, which is the one onInit a switched-off install adds, and only when it keeps a task. Translations are registered only for the labels of the collections the admin lists. The options are checked as when the plugin runs, except that adapters may be empty, which keeps no adapter's collections. The collection checks run too, and only their warnings go unsaid; their second pass over the assembled config runs only while the plugin is on.

Renaming the plugin's collections

Every collection the plugin registers takes a slug and an overrides function, under the option of the feature it belongs to:

CollectionDefault slugRegistered withOption
Raw hitsanalytics-hitsnative()native({ collections: { hits } })
Daily totalsanalytics-aggregatesnative()native({ collections: { aggregates } })
Salts, day markers, leases and heartbeatsanalytics-statenative(), else sync or warmingnative({ collections: { state } }); without native(), jobs.state
Read cacheanalytics-cacheevery install without cache.storecache.collection
Synced daily metricsanalytics-dailysyncsync.collection, which also takes hidden
Provider settingsanalytics-providersproviders.collectionproviders.collection, which also takes fields, access and more
Goalsanalytics-goalsgoals.collectiongoals.collection, which also takes fields, access and more

The default slugs are exported for code that queries these collections: CACHE_SLUG, GOALS_SLUG, PROVIDERS_SLUG, STATE_SLUG and SYNC_SLUG from @10x-media/analytics, and HITS_SLUG and AGGREGATES_SLUG from @10x-media/analytics/adapters/native. On an install that renames a collection, use the slug it set.

analytics({
  adapters: [
    native({
      collections: {
        hits: { slug: 'site-hits', overrides: (collection) => ({ ...collection, dbName: 'raw_hits' }) },
        aggregates: { slug: 'site-totals' },
        state: { slug: 'site-state' },
      },
    }),
  ],
  capture: { gate: 'all' },
  cache: { collection: { slug: 'site-cache' } },
  sync: { collection: { slug: 'site-daily' } },
})

analytics-state belongs to the native engine wherever native() is listed; jobs.state names it only on an install without it, where sync or warming registers it for their leases, and the boot refuses jobs.state anywhere else. It takes the key native() gives the same collection rather than collection, which under jobs would read as Payload's own payload-jobs.

slug is the only way to rename a collection, since every part of the plugin reads it under that slug: an overrides that returns another slug fails the boot, naming the option to set instead. overrides must be a function. It receives the collection as the plugin built it and returns the one to register, so spread what it keeps.

What an override may change: labels and admin settings, a dbName for the collection (its table on Postgres and SQLite, its collection on MongoDB), fields and indexes of your own, hooks, and access. Hooks never run on the rows the plugin writes, which go through payload.db or the engine's own SQL rather than the Local API.

What fails the boot, with a message naming the collection and the field:

  • on the hits, daily totals, state, cache and sync collections, which the plugin writes itself: removing a field the plugin defines, renaming it, changing its type, or moving it into a named group or a named tab (a row or collapsible around it is fine), and making one localized or hasMany;
  • on the same collections, dropping an index or a unique constraint the plugin declares;
  • on the same collections, making one of the plugin's optional fields required, or adding a required field without a fixed defaultValue: the plugin writes rows without it, and only a value becomes the column's default, while a function runs in Payload's hooks, which the plugin's writes skip;
  • on the hits, daily totals and state collections, a dbName on any of the plugin's fields: the engine's SQL names each column after its field;
  • on the providers and goals collections, timestamps: false: every instance notices a saved document by its updatedAt, so an edit on one server would never reach the others. Their fields are the editors' to shape, so the boot holds these two to their slug, their timestamps and the name limit below, and nothing else;
  • on Postgres, a table name, or an index name Payload builds from it (<table>_<column>_idx), over 63 characters, Postgres' limit. Choose a shorter slug, or a shorter dbName.

The same checks run again at init, and when a worker booted without onInit builds what it needs, against the config every plugin has assembled. A plugin that runs after analytics and changes one of these collections the way an override may not fails there, naming the collection; so does one that removes or renames one, or that replaces the custom the plugin marks its collections in (custom['@10x-media/analytics']) rather than spreading it, since the plugin finds its collections by that mark.

What the boot warns about, once at init: on the hits, daily totals, state, cache and sync collections, an override that replaces one of the plugin's access functions, or that turns on REST endpoints or GraphQL where the plugin keeps them off. The plugin reads and writes these rows below access control, so the warning is about who else can now reach them.

A new slug or dbName names a new table (on MongoDB, a new collection) and moves no rows: on an install that already has data, write a migration that moves them, or keep the names it has.

On this page