Services · Reference
Events
Cookieless analytics and transactional server audit events built on OTLP, with inherited actor context, trusted geography and event-derived visits.
Last updated October 21, 2026
Setup
Cookieless browser analytics, consent controls and server audit events over OTLP. SQL storage is optional.
bunx shadcn@latest add @krak-stack/service-eventsServer
Provide the telemetry foundation and connect Events to browser ingestion:
import { BrowserCrypto } from '@effect/platform-browser';
import { Layer } from 'effect';
import { Events } from '@/services/events';
import { Otlp } from '@/services/opentelemetry';
import { HttpApiOtlp } from '@/services/opentelemetry/api';
const application = Events.layerWith({ requireConsent: true }).pipe(
Layer.provide(BrowserCrypto.layer),
Layer.provideMerge(Otlp.layer),
);
const server = HttpApiOtlp.layer.pipe(
Layer.provide(Events.ingestionLayer),
Layer.provideMerge(application),
);Set APP_SECRET (stable, server-only, at least 32 characters), VITE_SITE_URL and your collector configuration:
OTEL_SERVICE_NAME=my-app-api
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
OTEL_LOGS_EXPORTER=otlpEvents.layerWith accepts partial overrides for origin, secret, serviceName, geolocation and requireConsent. Missing origin/secret resolve from the environment. Service name otherwise comes from Otlp. Use distinct service names per environment.
Browser
Mount this layer in your browser runtime, not during SSR:
import { Layer } from 'effect';
import { BrowserEvents } from '@/services/events/browser';
import { BrowserOtlp } from '@/services/opentelemetry/browser';
const browser = BrowserEvents.layer().pipe(
Layer.provideMerge(BrowserOtlp.layer({ serviceName: 'my-app-web' })),
);BrowserEvents requires acceptance by default. Mount the notice once in a persistent footer so visitors can choose and later change their preference.
Install
bunx shadcn@latest add @krak-stack/service-eventsTrackingNotice
English/French defaults are included; no application message files are required.
import { TrackingNotice } from '@/services/events/client/tracking-notice';
// Learn more opens the built-in dialog.
<TrackingNotice />
// Learn more links to your own policy instead.
<TrackingNotice privacyHref="/privacy-policy" />Footer Analytics preferences always opens the consent controls, including when privacyHref is provided. There is no default KrakStack link.
Locale
The component follows KrakstackProvider, with English fallback for unsupported languages:
import { KrakstackProvider } from '@/components/ui/krakstack-provider';
<KrakstackProvider locale="fr" locales={['en', 'fr']}>
<TrackingNotice privacyHref="/politique-de-confidentialite" />
</KrakstackProvider>
// Override the provider for one instance.
<TrackingNotice locale="fr" privacyHref="/politique-de-confidentialite" />Custom copy
Override only the messages you need. Use your application's translations for localized overrides.
<TrackingNotice
privacyHref="/privacy-policy"
messages={{
title: 'Help us understand opportunity interest',
description:
'With your permission, we record visits to public opportunity pages so organizations can understand interest in their postings.',
learnMore: 'Learn how analytics works',
}}
/>Available keys: preferences, title, description, learnMore, details, essential, privacy, accept, decline, close, blocked, accepted, declined, pending, error. Match your copy to the tracking actually configured in your application.
Tracking consent
| Feature, when configured | Accept | Decline / pending |
|---|---|---|
| Page views and custom analytics events | On | Off |
| UTM campaigns, referrers, geography and device details | On | Off |
| Daily visitor IDs, visits and identified analytics | On | Off |
| Web Vitals | On | Off |
| Essential browser error reports | On | On |
| Necessary security/audit records and server diagnostics | On | On |
- Accept: records the current page without replaying earlier page views.
- Decline, no choice or dismissal: optional analytics stays off.
- Saved choices: remembered for 180 days in a first-party preference cookie; missing, invalid, expired or outdated-version preferences return to pending.
- DNT/GPC: overrides acceptance without replacing the saved choice. No signal does not mean consent.
- Withdrawal: stops new optional events; queued event records are checked before serialization. Already-started exports, including their retries, are not cancelled. Stored history is not automatically deleted.
DNT is an older do-not-track request; GPC is primarily a sale/sharing opt-out signal. The default policy treats either as a request to disable optional analytics. Server Events enforces this policy only with requireConsent: true, as shown in Setup; its legacy default is false. In that mode, request-scoped server analytics also requires acceptance, and analytics without an HTTP request is suppressed. Necessary audit events remain available.
The preference cookie stores choice, notice version, timestamps and a consent revision—not a visitor identity. Bump ANALYTICS_NOTICE_VERSION when renewed consent is needed. requireConsent: false is an explicit deployment override, not a compliance exemption.
Cookieless does not mean anonymous
Analytics does not store visitor IDs in browser cookies or local storage. With trusted IP/browser information, the server can derive a daily-changing identifier: a limited form of fingerprinting. It is approximate, pseudonymous tracking. Daily rotation does not delete historical data or destroy the application secret used to derive it.
Your privacy policy should explain purposes, processors, retention and rights. Necessary diagnostics must not recreate declined analytics. Generic OTLP calls and third-party scripts are application-owned: gate or remove them separately. BrowserOtlp sanitizes automatic error messages and stack locations, but deployment logging and collector settings still need review. This is a product policy, not a legal-compliance guarantee. See EDPB device-access guidance and CNIL audience-measurement guidance.
Browser events
Run these Effects in the mounted browser runtime:
BrowserEvents.track('download.started', { format: 'pdf' });
BrowserEvents.page({ path: '/opportunities', name: 'Opportunities' });| Layer option | Default / behavior |
|---|---|
requireConsent | true; require shared acceptance |
respectDoNotTrack | true; honor DNT/GPC; shared consent also honors them |
pageViews | true; initial and SPA pathname views |
enabled | true; false disables BrowserEvents, not other telemetry |
locale | Optional locale callback |
sanitizePath | Replace personal path segments with route templates |
Properties become app.* attributes. Browser identity and audit claims are discarded. BrowserOtlp owns shared batching (30 seconds / 100 events by default); configure events on BrowserOtlp to adjust it.
Storage and delivery
Without SQL, events are enriched and forwarded to your collector. The collector owns storage and retention; delivery is best effort.
To enable application-owned history, provide a PostgreSQL or SQLite SqlClient to Events:
import { PgClient } from '@effect/sql-pg';
import { Config } from 'effect';
const sqlLayer = PgClient.layerConfig({ url: Config.Redacted('DATABASE_URL') });
const application = Events.layerWith({ requireConsent: true }).pipe(
Layer.provide(sqlLayer),
Layer.provide(BrowserCrypto.layer),
Layer.provideMerge(Otlp.layer),
);Events.layer / layerWith run migrations; baseLayer / baseLayerWith leave migrations to the application. Keep the same SqlClient available to services that share transactions.
| Table | Purpose |
|---|---|
events | Indexed event/identity/time fields and complete OTLP payload_json |
events_migrations | Migration history |
SQL is authoritative: failed writes fail recording. Successful new inserts emit to OTLP; duplicates do not. Forwarding failure does not undo committed browser analytics. There is no outbox or atomic SQL/OTLP delivery; a server event queued inside an outer transaction cannot be recalled if that transaction later rolls back.
Server events and audit
Use Otlp.event for collector-only telemetry. Use Events.track for optional SQL recording and audit validation:
const events = yield* Events;
yield* events.track({
eventId: `document:${requestId}:deleted`,
name: 'document.deleted',
category: 'audit',
target: { id: documentId, name: documentName, type: 'document' },
});Audit events require verified app.actor.type/id annotations and a typed target. Auth supplies actor and authentication context; do not trust browser-supplied identity. track returns { eventId, inserted }; without SQL, inserted is false even when telemetry is emitted. Suppressed analytics also returns false without emission.
| Context | Attributes |
|---|---|
| Responsible principal | app.actor.type, app.actor.id |
| Authentication | app.auth.method (session / api_key), app.auth.credential.id |
| User / tenant | user.id, user.roles, organization.id |
| Audit user snapshot | user.full_name, user.name, user.email |
| Audit target | app.audit.target.id/name/type |
Verified request annotations replace explicit fallback identity as a whole. For analytics ingestion, provide AuthMiddleware on /api/otel/v1/logs or supply an identity(request) Effect returning verified user/organization IDs or null. Resolver failures fail ingestion, rather than silently making events anonymous. Authorization remains application-owned. A key identifies its principal, not whether a human, agent or CLI operated it. Avoid sensitive target names/properties.
SQL deduplicates by (service_name, source, event_id). Reusing an ID with the same caller-owned content returns inserted: false; different content raises EventConflictError. Supply stable IDs for retryable operations.
AuditLogTable
Authorize tenant access before querying and fix the category server-side:
const page = yield* events.list({
organizationId,
category: 'audit',
page: 0,
pageSize: 20,
eventName: 'document.deleted',
globalFilter: 'document',
sort: [{ id: 'time', direction: 'desc' }],
});
<AuditLogTable
events={[{ value: 'document.deleted', label: m.document_deleted() }]}
entries={page.data}
rowCount={page.meta.total}
state={query}
onStateChange={setQuery}
/>list replaces listAudit and requires SQL, an organization ID and an explicit audit or analytics category. Results are scoped to the configured service and requested organization. Audit queries include only server-originated records; analytics queries include matching browser and server records. Filtering, sorting and pagination happen on the server. The optional eventName filter is an exact, case-sensitive match, combined with category and search before counting and pagination. The response contains unchanged EventLogRecord values plus pagination metadata. Sort IDs: time, eventName, recordType, record, actorType, actorId.
Use EventQuery from /events/schema for API query schemas and client atom types, and EventQueryStandard for route search validation. Both extend the shared table query with eventName. Keep that query through the entire API/client/route path so the selected action survives other table changes.
Pass the full application-owned event catalog through events, not just event names from the current page. Each option has an exact value and a localized label; supply labels from your application's translations. Controlled tables show an Action dropdown with a filter icon as the first toolbar menu. Selecting an option sets eventName and resets the page to zero; All actions clears it. The same labels appear in the Action column, with raw event names as fallback. A custom renderAction takes precedence.
The table displays Time, Action, Target, Actor and Method. User labels come from the recorded OpenTelemetry user.full_name, user.name and user.email attributes, with email or ID as secondary text. No Auth lookup, identity link or separate actors prop is needed. Missing snapshots retain neutral labels and recorded IDs. Trusted server name/email annotations are preserved for audit events and omitted from analytics; apply appropriate access and retention controls for personal data. Target names are also event-time snapshots: record a readable target.name when tracking the action. The Target column displays that name with type and ID as secondary text. When a name is missing or duplicates the ID, it shows the target type above the ID instead. Existing events are not backfilled or resolved against current entities. Method reads recorded authentication context, not inferred browser/operator information. Locale follows KrakstackProvider. The dateTime column defaults to America/New_York; override it with timeZone. Use locale-keyed messages, renderAction, loading and error for customization.
Migration
Replace Events.listAudit / ListAudit with Events.list / ListEvents and explicitly fix category: 'audit' in audit handlers. Remove AuditActor, the actors prop and browser profile lookups. Use EventQuery / EventQueryStandard instead of the generic query schemas where action filtering is needed, and supply the labeled events catalog.
Geography and visits
Geolocation is off by default. Configure geolocation: 'cloudflare' only behind trusted Cloudflare ingress with origin-bypass protection, or supply an Effect lookup returning validated IP/country/region/city fields. Raw IP/UA are not automatically persisted.
With SQL and trusted IP/UA, events share a visit within a 30-minute inactivity window. Daily visitor rotation splits new visits at UTC midnight; retries retain their original visit. Scope queries by service and verified tenant/user. Retain at least 30 minutes of event history if deriving visits.
Web Vitals
import { BrowserWebVitals } from '@/services/opentelemetry/web-vitals';
const browser = Layer.mergeAll(
BrowserEvents.layer(),
BrowserWebVitals.layer(),
).pipe(Layer.provideMerge(telemetry));Web Vitals requires acceptance before the page loads. Late acceptance starts it on the next full page load; withdrawal keeps it off for the rest of the current document. It uses the shared exporter and generic OTLP scope, without Events SQL storage. See OpenTelemetry for metrics and transport options.
Package imports and limits
Package consumers use @krak-stack/registry/events, /events/browser, /events/tracking-notice, /events/client (AuditLogTable), /events/schema, /events/record and /events/migrations. Otlp lives at /opentelemetry.
Browser ingestion uses /api/otel/v1/logs, JSON batches up to 64 KiB / 100 application records, matching Origin, and timestamps from the previous hour through one minute ahead. Generic events remain separate from the krakstack.application.events scope. Apply edge rate limits, permissions, retention and backups; avoid personal data and high-cardinality metric/Loki labels.