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:

TypeScript
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}
TypeScript22 lines

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.

TypeScript
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 }),  ],})
TypeScript28 lines

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:

TypeScript
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()
TypeScript14 lines

The recorder's own surface is small, the same DecisionRecorder type imported above:

TypeScript
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}
TypeScript9 lines

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.

SinkFactoryYou pass
SnowflakecreateSnowflakeSinka connection and a table name
BigQuerycreateBigQuerySinka client, a dataset, and a table
RedshiftcreateRedshiftSinka client and a table name
ClickHousecreateClickHouseSinka client and a table name
OpenTelemetrycreateOtelSinkan exporter, and optionally a span name
SentrycreateSentrySinkyour Sentry client, and optionally a breadcrumb category
DatadogcreateDatadogSinkan 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.

TypeScript
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',})
TypeScript14 lines
Tip

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.

SDKHow decisions are collectedSinks
@avsbhq/nodeforUser(ctx).getDecisionLog().entries per request, or the evaluation and exposure events on the serverThe seven @avsbhq/utils sinks above, fed through a DecisionRecorder you own
Pythonserver.flush_decision_log() drains what has accumulatedForward the drained entries yourself
Goserver.SetDecisionLog(log), with avsb.NewMemoryDecisionLog(1000) for a bounded in-memory logImplement LogDecision(entry) and write where you like
TypeScript
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)
TypeScript25 lines
  • Multi-context identity: what contextSummary carries.
  • Bandits: BanditDecisionLogEntry extends 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's CleanupRegistry.
Was this helpful?