Decision logging
Decision logging captures every flag evaluation as a structured DecisionLogEntry and forwards it to sinks you configure: your data warehouse, an observability tool, or both. This is different from the built-in exposure event (the moment a visitor is counted in an experiment, usually when they see the tested part) pipeline. That pipeline sends data to A vs B's own analytics. Decision logs go to your own infrastructure instead.
What it is
The DecisionLogEntry shape carries everything you need to join flag decisions against your own event streams:
import type { EvaluationSource, RuleType } from '@avsbhq/core'interface DecisionLogEntry { flagKey: string /** '[REDACTED]' when a private attribute appeared in it. */ value: unknown variationKey: string | null /** 'rule' | 'holdout' | 'bandit' | 'default' and the rest. */ source: EvaluationSource ruleId: string | null ruleType: RuleType | null /** Private attribute values replaced with '[REDACTED]'. */ reasons: string[] /** Milliseconds since the epoch. */ evaluatedAt: number /** Microseconds spent in the evaluator. */ durationMicros: number /** Kind and key only. Attribute values are never logged. */ contextSummary: Array<{ kind: string; key: string }> /** Set when manualExposure produced the entry, rather than an automatic read. */ manuallyFired?: boolean}Notice that contextSummary carries only kind and key: attribute values are never in the log entry, even non-private ones. The reasons array can contain attribute-value pairs from audience (a named, reusable group of visitors defined by rules about who belongs) condition checks. Any value that touched a private attribute is replaced with [REDACTED] before any sink receives it.
When to use it
Decision logging is most useful when:
- You want to join flag decisions with your own clickstream or product analytics data in a warehouse.
- You need an audit trail of which flag value each user received at what time, for compliance, debugging, or customer support.
- You are running bandits and want to analyse model decisions and reward probabilities in your own BI tool.
- You want to forward flag decisions to Sentry, Datadog, or OpenTelemetry for correlation with error traces.
How it works
A DecisionRecorder takes a sampling rate, a list of attribute keys to redact, and one or more sinks. It buffers entries and calls each sink's write(batch) on the flush interval (default 5 seconds, or sooner once bufferSize entries are held, default 1000). Sink errors never propagate into evaluation.
import { createDecisionRecorder, createOtelSink, createSnowflakeSink } from '@avsbhq/utils'import type { DecisionRecorder } from '@avsbhq/utils'import { SDK_VERSION } from '@avsbhq/node'// Your Snowflake connection, in the shape the sink expects.declare const snowflakeConnection: { execute(opts: { sqlText: string; binds?: unknown[][] }): Promise<{ rowCount: number }>}declare const otelExporter: { export(spans: Array<{ name: string; attributes: Record<string, unknown>; startTime: number }>): Promise<void>}const recorder: DecisionRecorder = createDecisionRecorder({ sample: 0.1, // record 10% of decisions privateAttributes: ['email', 'ip'], // redact these in values and reasons bufferSize: 500, flushInterval: 10_000, // flush every 10 seconds sinks: [ createSnowflakeSink({ connection: snowflakeConnection, table: 'avsb_decisions', // Both required: every row records which environment wrote it. sdkKey: process.env.AVSB_SDK_KEY ?? '', sdkVersion: SDK_VERSION, }), createOtelSink({ exporter: otelExporter }), ],})Every factory above ships on the ROOT @avsbhq/utils entry point, which arrives with @avsbhq/node and @avsbhq/browser. There is no @avsbhq/utils/decisions subpath.
Feeding the recorder
The recorder is a standalone component: no SDK takes a decisionRecorder option and starts filling it for you. You decide what reaches it, which is what makes sampling and redaction yours to control. On the Node SDK, the request-scoped decision log is the natural source:
import { AvsbServer } from '@avsbhq/node'const server = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY ?? '' })await server.onReady()const user = server.forUser({ kind: 'user', key: 'u_123', plan: 'pro' })user.getBoolFlag('checkout_v2', false)// At the end of the request, hand what it decided to the recorder.for (const entry of user.getDecisionLog().entries) { recorder.record(entry)}await recorder.flush()The recorder's own surface is small, the same DecisionRecorder type imported above:
interface DecisionRecorder { record(entry: DecisionLogEntry): void /** Flush the buffer immediately. */ flush(): Promise<void> /** Drain its own timer and flush once more. */ close(): Promise<void> /** Entries currently buffered, useful in tests. */ bufferedCount(): number}Want every evaluation instead of just one request's worth? Subscribe to the server's evaluation event. It fires for each read, whether or not an exposure was recorded, and you build your own entry from it. The exposure event carries the same eventId the ingestion pipeline receives, so rows reconcile across systems.
The sinks that ship
Every one of these is a DecisionSink: a name and a write(batch) method. They write directly to your destination, with no intermediate message queue.
| Sink | Factory | You pass |
|---|---|---|
| Snowflake | createSnowflakeSink | a connection and a table name |
| BigQuery | createBigQuerySink | a client, a dataset, and a table |
| Redshift | createRedshiftSink | a client and a table name |
| ClickHouse | createClickHouseSink | a client and a table name |
| OpenTelemetry | createOtelSink | an exporter, and optionally a span name |
| Sentry | createSentrySink | your Sentry client, and optionally a breadcrumb category |
| Datadog | createDatadogSink | an API key, and optionally a site, tags, and service name |
The four warehouse sinks each ship a .sql file next to them carrying the CREATE TABLE statement their row shape expects. SNOWFLAKE_CREATE_TABLE is also exported as a string.
import { createDatadogSink, createSentrySink } from '@avsbhq/utils'declare const sentryClient: { addBreadcrumb(crumb: { category: string; message: string }): void}// Flag decisions as Sentry breadcrumbs, so an error trace shows what was on.const sentrySink = createSentrySink({ client: sentryClient })// Decisions as Datadog logs.const datadogSink = createDatadogSink({ apiKey: process.env.DD_API_KEY ?? '', site: 'datadoghq.com',})For high-volume deployments, set sample to something between 0.01 and 0.05 and raise bufferSize, so one write carries many decisions.
Per-SDK reality
The entry shape is identical everywhere. How you get hold of the entries is not, so here is each server SDK as it actually ships.
| SDK | How decisions are collected | Sinks |
|---|---|---|
@avsbhq/node | forUser(ctx).getDecisionLog().entries per request, or the evaluation and exposure events on the server | The seven @avsbhq/utils sinks above, fed through a DecisionRecorder you own |
| Python | server.flush_decision_log() drains what has accumulated | Forward the drained entries yourself |
| Go | server.SetDecisionLog(log), with avsb.NewMemoryDecisionLog(1000) for a bounded in-memory log | Implement LogDecision(entry) and write where you like |
import { AvsbServer } from '@avsbhq/node'import { createClickHouseSink, createDecisionRecorder } from '@avsbhq/utils'declare const clickhouse: { insert(params: { table: string; format: string; values: unknown[] }): Promise<void>}const warehouseRecorder = createDecisionRecorder({ sample: 0.05, sinks: [ createClickHouseSink({ client: clickhouse, table: 'avsb_decisions', sdkKey: process.env.AVSB_SDK_KEY ?? '', sdkVersion: SDK_VERSION, }), ],})const warehouseServer = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY ?? '' })// One request: evaluate, then forward what it decided.const request = warehouseServer.forUser({ kind: 'user', key: 'u_123' })request.getBoolFlag('checkout_v2', false)for (const entry of request.getDecisionLog().entries) warehouseRecorder.record(entry)Related concepts
- Multi-context identity: what
contextSummarycarries. - Bandits:
BanditDecisionLogEntryextends the base shape. - Cleanup registry: a separate mechanism. The decision recorder drains its own timer in its own
close(), so call that yourself. It is not wired into any SDK client'sCleanupRegistry.