/Docs

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:

ts
1interface DecisionLogEntry {
2 flagKey: string
3 value: unknown // [REDACTED] if private attributes appeared in it
4 variationKey: string | null
5 source: EvaluationSource // 'rule' | 'holdout' | 'bandit' | 'default' | ...
6 ruleId: string | null
7 ruleType: RuleType | null
8 reasons: string[] // private attribute values replaced with [REDACTED]
9 evaluatedAt: number // ms-epoch
10 durationMicros: number
11 contextSummary: Array<{ kind: string; key: string }> // no attribute values
12}

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:

ts
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 decisions
7 privateAttributes: ['email', 'ip'], // redact these in reasons
8 bufferSize: 500,
9 flushInterval: 10_000, // flush every 10 s
10 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:

ts
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
Tip
Warehouse sinks buffer writes internally and retry on transient failures. For high-volume deployments, set 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:

ts
1import { createSentrySink } from '@avsbhq/utils/decisions/sentry'
2import { createDatadogSink } from '@avsbhq/utils/decisions/datadog'
3
4// Attach flag decisions to the active Sentry scope
5const sentrySink = createSentrySink({ client: Sentry })
6
7// Forward to Datadog custom metrics
8const datadogSink = createDatadogSink({
9 apiKey: process.env.DD_API_KEY,
10 site: 'datadoghq.com',
11})

Per-SDK usage

@avsbhq/node
ts
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})
avsb-python
python
1from avsb import AvsbServer
2from avsb.decisions import create_decision_recorder
3from avsb.decisions.bigquery import create_bigquery_sink
4from google.cloud import bigquery
5
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)
avsb-go
go
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())
avsb-java
java
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();