Decision Logging
Decision logging captures every flag evaluation as a structured DecisionLogEntry and forwards it to the sinks you configure — your data warehouse, an observability tool, or both. Unlike the built-in exposure event pipeline (which sends data to A vs B analytics), decision logs go to your own infrastructure.
What it is
The DecisionLogEntry shape carries everything you need to join flag decisions against your own event streams:
1interface DecisionLogEntry {2 flagKey: string3 value: unknown // [REDACTED] if private attributes appeared in it4 variationKey: string | null5 source: EvaluationSource // 'rule' | 'holdout' | 'bandit' | 'default' | ...6 ruleId: string | null7 ruleType: RuleType | null8 reasons: string[] // private attribute values replaced with [REDACTED]9 evaluatedAt: number // ms-epoch10 durationMicros: number11 contextSummary: Array<{ kind: string; key: string }> // no attribute values12}Notice that contextSummary carries only kind and key — attribute values are never in the log entry, even non-private ones. The reasons array may contain attribute-value pairs from audience condition evaluation; 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
You create a DecisionRecorderwith a sampling rate, a list of attribute keys to redact, and one or more sinks. The recorder receives a batch of entries on each flush interval (default 5 seconds, max 1 000 entries before forced flush) and calls each sink's write(batch) method asynchronously:
1import { createDecisionRecorder } from '@avsbhq/utils/decisions'2import { createSnowflakeSink } from '@avsbhq/utils/decisions/snowflake'3import { createOtelSink } from '@avsbhq/utils/decisions/otel'4
5const recorder = createDecisionRecorder({6 sample: 0.1, // record 10% of decisions7 privateAttributes: ['email', 'ip'], // redact these in reasons8 bufferSize: 500,9 flushInterval: 10_000, // flush every 10 s10 sinks: [11 createSnowflakeSink({ connection: sfConn, table: 'avsb_decisions' }),12 createOtelSink({ exporter: otelExporter }),13 ],14})Attach the recorder to your server instance so it receives every evaluation automatically:
1const server = new AvsbServer({2 sdkKey: process.env.AVSB_SDK_KEY,3 decisionRecorder: recorder,4})5await server.onReady()Warehouse sinks
A vs B ships native warehouse adapters that write directly to your table without an intermediate message queue. Each adapter bundles a.sql schema file with the CREATE TABLE statement:
- Snowflake —
@avsbhq/utils/decisions/snowflake - BigQuery —
@avsbhq/utils/decisions/bigquery - Redshift —
@avsbhq/utils/decisions/redshift - ClickHouse —
@avsbhq/utils/decisions/clickhouse
sample to 0.01–0.05 and increase bufferSize to reduce write amplification.Observability transports
For trace correlation and error monitoring, three additional sinks are available:
1import { createSentrySink } from '@avsbhq/utils/decisions/sentry'2import { createDatadogSink } from '@avsbhq/utils/decisions/datadog'3
4// Attach flag decisions to the active Sentry scope5const sentrySink = createSentrySink({ client: Sentry })6
7// Forward to Datadog custom metrics8const datadogSink = createDatadogSink({9 apiKey: process.env.DD_API_KEY,10 site: 'datadoghq.com',11})Per-SDK usage
1import { AvsbServer } from '@avsbhq/node'2import { createDecisionRecorder } from '@avsbhq/utils/decisions'3import { createClickHouseSink } from '@avsbhq/utils/decisions/clickhouse'4import { createClient } from '@clickhouse/client'5
6const ch = createClient({ url: process.env.CLICKHOUSE_URL })7
8const server = new AvsbServer({9 sdkKey: process.env.AVSB_SDK_KEY,10 decisionRecorder: createDecisionRecorder({11 sample: 0.05,12 sinks: [createClickHouseSink({ client: ch, table: 'avsb_decisions' })],13 }),14})1from avsb import AvsbServer2from avsb.decisions import create_decision_recorder3from avsb.decisions.bigquery import create_bigquery_sink4from google.cloud import bigquery5
6bq = bigquery.Client()7
8server = AvsbServer(9 sdk_key=os.environ["AVSB_SDK_KEY"],10 decision_recorder=create_decision_recorder(11 sample=0.1,12 private_attributes=["email"],13 sinks=[create_bigquery_sink(client=bq, dataset="analytics", table="avsb_decisions")],14 ),15)1import (2 "github.com/avsbhq/avsb-go"3 "github.com/avsbhq/avsb-go/decisions"4 "github.com/avsbhq/avsb-go/decisions/redshift"5)6
7client, _ := avsb.NewServer(avsb.Options{8 SDKKey: os.Getenv("AVSB_SDK_KEY"),9 DecisionRecorder: decisions.NewRecorder(decisions.Options{10 Sample: 0.1,11 Sinks: []decisions.Sink{redshift.New(redshiftClient, "avsb_decisions")},12 }),13})14client.WaitForReady(context.Background())1import cloud.avsb.AvsbServer;2import cloud.avsb.decisions.DecisionRecorder;3import cloud.avsb.decisions.SnowflakeSink;4
5AvsbServer server = AvsbServer.builder()6 .sdkKey(System.getenv("AVSB_SDK_KEY"))7 .decisionRecorder(8 DecisionRecorder.builder()9 .sample(0.1)10 .sink(new SnowflakeSink(sfConnection, "AVSB_DECISIONS"))11 .build()12 )13 .build();