Skip to content

Analytics

Analytics lets your App track what your Visitors do — named behavior events and identity profiles — from your handler code, routed to a provider-selectable backend (PostHog by default, with Amplitude and GA4 selectable at the platform level). This Capability is needs-provider-setup: it requires your own provider account and key, and Anyknown has not yet smoke-verified end-to-end delivery against a live Customer key. The SDK surface on this page is final, and the contract is stable to build against; what is unverified is the last hop to a real provider account you supply. Until you set up your provider and store its secret, no events reach the provider.

Your App always calls sdk.analytics.*. The platform selects the provider, injects credentials, scrubs PII from your event properties, enforces the consent gate, applies deterministic sampling, and normalizes errors. Your handler never talks to PostHog, Amplitude, or GA4 directly, and the provider key never appears in your source or logs.

The single most important thing to know up front: every analytics call is fire-and-forget. Each method returns Promise<void> and always resolves. Provider failures, a missing key, rate limits, and consent blocks are all swallowed by the SDK — they never throw out of sdk.analytics.* and never add latency to your response. You call analytics to record what happened, not to get a result back.

Analytics is available once the secret your provider needs is set. Use set_secret to store it — you never paste it into code; the SDK reads it through the platform on each call. The required secret depends on the provider selected for your App.

ProviderRequired secretOptional secrets
PostHog (default)POSTHOG_PROJECT_API_KEYPOSTHOG_HOST (EU endpoint or self-hosted URL)
AmplitudeAMPLITUDE_API_KEYAMPLITUDE_HOST (EU endpoint)
GA4GA4_API_SECRETGA4_MEASUREMENT_ID (also required), GA4_HOST (EU endpoint)

A few key-format rules the SDK enforces:

  • PostHogPOSTHOG_PROJECT_API_KEY must look like phc_… (the phc_ prefix followed by 20+ characters). A mismatched value surfaces internally as kind: "auth" (status 502) at call time.
  • AmplitudeAMPLITUDE_API_KEY must be a 32-character lowercase hex string; a mismatch surfaces as kind: "auth" (status 502).
  • GA4GA4_MEASUREMENT_ID is required in addition to GA4_API_SECRET, and must match the G-XXXXXXXX format. Its absence surfaces as kind: "secret-missing" (status 412) on every call.

These kind/status values are what the SDK records internally; because analytics is fire-and-forget, they do not throw out of your call (see Errors).

The provider itself is selected once per App in the Anyknown Cloud console, not in your code — moving from PostHog to Amplitude or GA4 changes only the platform configuration and your secret. Your sdk.analytics.* calls stay identical.

type ConsentState = "granted" | "denied" | "pending"
type TrackEventInput = {
event: string // 1–200 chars
distinctId: string // 1–256 chars
properties?: Record<string, unknown>
timestamp?: string // optional ISO 8601 datetime
consent?: ConsentState // absent = treated as "granted"
}
type IdentifyInput = {
distinctId: string // 1–256 chars
properties?: Record<string, unknown> // sets/updates profile properties
setOnce?: Record<string, unknown> // set only on first call (PostHog/Amplitude)
consent?: ConsentState
}
type AliasInput = {
fromDistinctId: string // 1–256 chars; the anonymous id
toDistinctId: string // 1–256 chars; the signed-in user id
consent?: ConsentState
}
type AnalyticsCapability = {
track(input: TrackEventInput): Promise<void>
identify(input: IdentifyInput): Promise<void>
alias?(input: AliasInput): Promise<void> // optional — not on GA4
flush?(): Promise<void> // optional — v1 no-op
}

You reach these through the SDK from inside a handler:

const sdk = c.get("sdk")
void sdk.analytics.track({
event: "checkout_completed",
distinctId: visitorId,
properties: { plan: "pro", currency: "USD" },
})

Note the void — it is the canonical pattern. Because the returned Promise always resolves, awaiting an analytics call only adds latency to your response with no correctness benefit. Use void to fire it and move on.

track records a named Visitor event. It is fire-and-forget and always resolves.

track(input: TrackEventInput): Promise<void>
  • event — the event name (1–200 chars), e.g. "signup_started", "item_added_to_cart".
  • distinctId — a stable identifier for the Visitor (1–256 chars). Use the same value across every event for a given Visitor so their timeline stays coherent.
  • properties — an optional key/value bag describing the event. These are forwarded to the provider after PII scrubbing.
  • timestamp — optional ISO 8601 string; omit it to use the time of the call.
  • consent — optional; see The consent gate.
const sdk = c.get("sdk")
void sdk.analytics.track({
event: "article_read",
distinctId: visitorId,
properties: { articleId: "1042", readSeconds: 95 },
consent: visitorConsent,
})

track is the only method subject to sampling (see Deterministic sampling).

identify sets or updates a Visitor’s profile on the analytics provider — attributes like plan tier, signup date, or locale that describe who the Visitor is rather than what they just did.

identify(input: IdentifyInput): Promise<void>
  • properties — profile attributes to set or update on every call.
  • setOnce — attributes written only on the first identify for that distinctId (PostHog and Amplitude); useful for first-seen values like an acquisition channel.
const sdk = c.get("sdk")
void sdk.analytics.identify({
distinctId: visitorId,
properties: { plan: "pro" },
setOnce: { firstSeenAt: new Date().toISOString() },
consent: visitorConsent,
})

identify is never sampled — when consent allows, it always fires.

Stitching anonymous and signed-in identities — alias

Section titled “Stitching anonymous and signed-in identities — alias”

alias links an anonymous fromDistinctId to a known toDistinctId, merging the Visitor’s pre-login event history into their authenticated identity. Call it once, at sign-in time.

alias(input: AliasInput): Promise<void>
const sdk = c.get("sdk")
void sdk.analytics.alias({
fromDistinctId: anonymousSessionId,
toDistinctId: authenticatedUserId,
consent: visitorConsent,
})

The recommended identity pattern: use a stable session identifier as the distinctId for an anonymous Visitor, track events against it, then call alias the moment they authenticate so their earlier events merge into the signed-in user.

alias is not available on GA4. GA4 has no identity-merge concept, so on a GA4-configured App an alias call resolves as a no-op (the underlying not-available, status 501, is swallowed — fire-and-forget still holds). PostHog and Amplitude support it. alias is optional on the AnalyticsCapability type; if you branch on availability, check whether the method is present rather than calling it blindly.

flush exists as an API-surface placeholder so a handler that wants to drain pending events before, say, a redirect can call it without a version guard.

flush(): Promise<void>

In v1 it is a no-op — it resolves immediately without sending anything. Do not rely on it for guaranteed delivery; it is a forward-compatible seam, not a flush you can depend on today.

The consent gate is caller-supplied, per call. The SDK enforces the gate but cannot determine a Visitor’s consent state on its own — it does not read cookies or infer anything. Your handler must pass consent when your App has a consent mechanism.

  • consent: "granted" — the call proceeds.
  • consent absent — treated as "granted".
  • consent: "denied" or consent: "pending" — the call silently short-circuits. Nothing is sent to the provider, and no error is raised.
const sdk = c.get("sdk")
// visitorConsent is "granted" | "denied" | "pending", determined by your App
void sdk.analytics.track({
event: "page_viewed",
distinctId: visitorId,
consent: visitorConsent,
})

Pass the consent state on every track, identify, and alias call where your App has a consent signal. When in doubt about a Visitor’s status before consent resolves, pass "pending" — it short-circuits cleanly until you know.

Set ANALYTICS_SAMPLE_RATE (a float 01, default 1 = all events) to send only a fraction of track events. Sampling is deterministic per distinctId, computed from a sha256 hash of the id rather than a per-call coin flip. The consequence: a given Visitor is consistently either in-sample or out-of-sample, so each sampled-in Visitor’s timeline stays complete instead of having random gaps.

identify and alias are exempt from sampling — they always fire when consent allows, regardless of ANALYTICS_SAMPLE_RATE.

These env bindings tune Analytics behavior. The provider is chosen in the Anyknown Cloud console; the rest are env bindings.

Env varPurposeDefault
ANALYTICS_PROVIDERSelects the provider: posthog / amplitude / ga4posthog
ANALYTICS_SAMPLE_RATEFloat 01; deterministic per-distinctId sampling applied to track only (identify/alias exempt)1 (all events)
ANALYTICS_SCRUB_PATTERNSJSON rules for scrubbing PII out of properties before send — key-regex matches with redact / drop / hash actionsnone

ANALYTICS_SCRUB_PATTERNS rules are compiled once when the App starts. A misconfigured pattern is non-fatal: it falls back to no scrubbing and records the problem in internal observability rather than failing your calls.

type AnalyticsErrorKind =
| "secret-missing" // required provider key not set
| "validation" // input failed validation (e.g. Amplitude distinctId < 5 chars)
| "auth" // provider rejected the key
| "rate-limited" // provider/platform throttle; retryAfterSec may be set
| "provider-error" // upstream 5xx or network failure
| "not-available" // alias called on GA4

Under the default fire-and-forget wiring, AnalyticsError is never thrown out of sdk.analytics.* — every kind above is swallowed and surfaced only through internal observability. Do not wrap analytics calls in try/catch expecting to catch one; there is nothing to catch. The class is exported only for the narrow case where you attach an explicit .catch() to opt into seeing failures yourself.

  • Always void, never await. void sdk.analytics.track(...) is the canonical pattern. The call always resolves, so awaiting only adds latency.

  • Consent is yours to supply. The SDK enforces the gate but cannot read a Visitor’s consent; pass consent on every call where your App has a signal. Absent means "granted"; "denied" and "pending" silently skip the send.

  • Keep distinctId stable, then alias at sign-in. Use one consistent identifier per Visitor, and call alias when they authenticate to merge their pre-login history (PostHog and Amplitude; GA4 has no such concept).

  • Sampling is deterministic. The same Visitor is always in- or out-of-sample; identify and alias never sample out.

  • Never spread raw request headers into properties. Whatever you put in properties is forwarded to the provider. Spreading c.req.raw.headers would leak IP addresses, cookies, and auth tokens to a third party. Pass only the specific values you mean to record.

  • The SDK is the only correct path. Calling a provider’s API directly with fetch bypasses credential injection, PII scrubbing, the consent gate, sampling, and error normalization. Always go through sdk.analytics.*.

  • Amplitude needs distinctId of at least 5 characters. Shorter ids fail validation internally on Amplitude (kind: "validation", status 400); PostHog and GA4 have no minimum.

  • Provider choice is invisible to your code. Moving between PostHog, Amplitude, and GA4 changes only the platform configuration and your secret — your sdk.analytics.* calls stay identical, and the provider SDKs never enter your App’s bundle.