Skip to content

Tracking Events

All backend operations emit structured wide events to Axiom. Each event is a single JSON object capturing the full lifecycle of a unit of work — webhook receipt, cache invalidation batch, or deploy build.

Storefront per-request telemetry is not in scope. Only admin/system operations are instrumented.

SHOPIFY CF-TRK CF-CLIENT-DEPLOY
─────── ────── ────────────────
webhook ──▶ webhook:received ──▶ webhook:invalidate
│ enrichAndEmit()
│ └─ D1 lookup (page_deps)
│ └─ affectedUrls[]
│ deploy:started
│ deploy:complete
└─ attribution webhooks: skip Axiom
THEME WORKER (cf-client-site)
──────────────────────────────
render ──▶ DependencyTracker ──▶ flushDeps() ──▶ D1 page_deps
tracks products, waitUntil() (shop_id, url,
collections, pages res_type, res_id)

Two Axiom datasets, each with its own scoped API token:

DatasetEnv VarWorkersPurpose
lk-event-logsAXIOM_EVENT_API_TOKEN + AXIOM_EVENT_DATASET_NAMEcf-trk, cf-client-deployWide operational events
lk-build-logsAXIOM_BUILD_API_TOKEN + AXIOM_BUILD_DATASET_NAMEcf-client-deploy (container)Granular per-step build logs

The event logs dataset captures one event per operation with scalar aggregates. The build logs dataset captures many events per build with per-step detail (see Build Logging).

Every event follows the LKEvent interface from @lk/config/observability:

interface LKEvent {
source: 'cf-trk' | 'cf-client-deploy' | 'cf-app' | 'agent'
action: string // e.g. 'webhook:received', 'deploy:complete'
clientId?: string // client slug when applicable
correlationId: string // UUID for cross-referencing
_time: string // ISO timestamp
durationMs: number // wall-clock duration
ok: boolean // false on error
errors: string[] // error messages if !ok
}

Actions extend this with domain-specific fields.

Emitted for every Shopify webhook except attribution webhooks (orders/checkouts), which set _skip = true and are not sent to Axiom.

The webhook handler always returns HTTP 200 to Shopify regardless of internal errors — Shopify disables webhook endpoints after repeated non-200 responses.

Emission uses waitUntil() via enrichAndEmit() to avoid blocking the <100ms response requirement. Before emitting, the function performs an async D1 lookup against the page_deps table to resolve which storefront URLs would be affected by the resource change.

FieldTypeDescription
shopIdstringShopify myshopify domain
topicstringShopify webhook topic (e.g. products/update)
hmacValidbooleanHMAC validation result
handlerstringResolved handler: attribution, deploy, invalidation, or ignored
enqueuedbooleanWhether an invalidation event was queued
deployBuildEnqueuedbooleanWhether a staging rebuild was triggered
resTypestringResource type: product, collection, theme, etc.
resIdstringShopify numeric resource ID (or * for nuclear)
resTitlestring?Resource title from webhook payload (e.g. product title)
resHandlestring?Resource handle from webhook payload
affectedUrlsstring[]Storefront URLs that depend on this resource (from page_deps D1)

Emitted per queue batch. Purge failures are non-fatal — if the CDN purge API returns an error, the event is logged but the batch is not retried (content stays cached until TTL).

FieldTypeDescription
batchSizenumberRaw messages in batch
nuclearCountnumberNuclear (full-shop) invalidations
targetedCountnumberTargeted (per-resource) invalidations
totalPurgednumberCDN cache entries purged
totalKvEvictednumberKV cache keys evicted
skippednumberEvents skipped (unknown shop, no deps)

Beacon emitted immediately when a build begins. Fire-and-forget — no additional fields beyond LKEvent.

Wide event emitted after the container stream finishes or errors.

FieldTypeDescription
buildIdstringBuild UUID
httpStatusnumberContainer response status code
streamedOkbooleanWhether NDJSON stream was received

The theme worker (cf-client-site) maintains a dependency map in D1 that records which Shopify resources each rendered page depends on. This map powers the affectedUrls field on webhook:received events and drives targeted cache invalidation.

  1. During SSR, the DependencyTracker records every product, collection, page, blog, and article loaded from KV
  2. After the response is sent, flushDeps() writes the deps to the page_deps D1 table via waitUntil()
  3. When a webhook fires, enrichAndEmit() queries page_deps to find which URLs reference the changed resource
  4. The queue consumer (processEvent) uses the same lookup to determine which URLs to purge from the CDN cache
CREATE TABLE page_deps (
shop_id TEXT NOT NULL,
url TEXT NOT NULL,
res_type TEXT NOT NULL,
res_id TEXT NOT NULL,
PRIMARY KEY (shop_id, url, res_type, res_id)
);

Each row maps a storefront URL to a single resource dependency. A page rendering 50 products creates 50 rows (plus collections, theme, etc.).

Only real storefront routes are recorded. The worker validates the request path before flushing:

const isStorefrontPath =
depPath === "/" ||
/^\/(collections|products|pages|blogs|cart|search)(\/|$)/.test(depPath);

This prevents bot/scanner probes (/.env, /.git/config, encoded asset URLs) from polluting the dependency map.

The dashboard (cf-app) consumes these Axiom events via the Activity tab. Events are fetched from the lk-event-logs dataset and displayed in a day-grouped feed.

For webhook:received events, the headline shows the resource title (e.g. “Product updated: Farm Suit Pro”) and the detail panel shows resource metadata, handle, ID, and the affected pages list from the dependency map.

The createObservability() factory from @lk/config/observability produces event() and emit() functions scoped to a source:

import { createObservability } from '@lk/config/observability'
const lk = createObservability('cf-trk', env)
const ev = lk.event('webhook:received', { clientId: slug })
// ... enrich ev with domain fields ...
ev.ok = false
ev.errors.push('something broke')
// emit computes durationMs and strips internal _start field
await lk.emit(ev)

emit() swallows fetch errors — observability must never crash the operation it’s observing. In Workers, wrap the emit in waitUntil() when the response has already been sent.

For webhook:received, emission is wrapped in enrichAndEmit() which runs a D1 lookup before emitting. The lookup is non-fatal — if it fails, the base event is emitted without affectedUrls:

async function enrichAndEmit(ev, db, lk) {
try {
if (ev.resType && ev.resId && ev.resId !== '*' && ev.shopId) {
ev.affectedUrls = await lookupAffectedUrls(db, ev.shopId, ev.resType, ev.resId);
}
} catch { /* emit base event anyway */ }
await lk.emit(ev);
}
  • Analytics Engine in cf-trk: ANALYTICS binding writes invalidation_events via logInvalidation(). Per-URL granular data for invalidation debugging.
  • Axiom build logs from Docker container: scripts/lib/axiom.ts buffers and flushes per-step build events to lk-build-logs. Node.js only, runs inside the container.
  • ClientHub DO + WebSocket: broadcasts NDJSON build events to dashboard viewers in real-time.
FilePurpose
packages/config/src/observability/types.tsLKEvent type, EventSource union
packages/config/src/observability/emit.tscreateObservability() factory
packages/cf-trk/src/webhooks/handler.tswebhook:received event + enrichAndEmit()
packages/cf-trk/src/webhooks/resource-map.tsTopic → resource type mapping + ID extraction
packages/cf-trk/src/queue/consumer.tswebhook:invalidate event
packages/cf-trk/src/queue/process-event.tsPer-event processing (URL lookup → purge)
packages/cf-trk/src/queue/purge.tslookupAffectedUrls() D1 query
packages/cf-client-site/src/lib/dependency-tracker.tsDependencyTracker class + flushDeps()
packages/cf-client-deploy/src/worker.tsdeploy:started + deploy:complete events
packages/cf-app/src/components/stats/activity-tab.tsxDashboard Activity tab UI
packages/cf-app/src/components/stats/data.tsActivityEvent type + headline formatting
packages/cf-app/src/lib/event-data.tsAxiom query + response mapping
packages/cf-trk/src/analytics/log.tsAnalytics Engine (unchanged)
scripts/lib/axiom.tsBuild log buffer (unchanged)