@sentrial/sdk 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,6 +3,7 @@
3
3
  TypeScript SDK for [Sentrial](https://sentrial.com) - AI agent observability and monitoring.
4
4
 
5
5
  Track sessions, tool calls, and metrics to power:
6
+
6
7
  - **Signal detection**: Auto-detect patterns and anomalies
7
8
  - **Root cause analysis**: Understand WHY agents fail
8
9
  - **Experiments**: A/B test different system prompts
@@ -18,6 +19,66 @@ pnpm add @sentrial/sdk
18
19
  yarn add @sentrial/sdk
19
20
  ```
20
21
 
22
+ ## Breaking changes
23
+
24
+ **0.5.0**
25
+
26
+ - **Transport is now OpenTelemetry (OTLP).** Telemetry ships over `/api/otel/v1/traces`; this SDK no
27
+ longer POSTs sessions/events to the legacy `/api/sdk/*` endpoints (those endpoints remain available
28
+ server-side as thin compatibility adapters that translate onto the OTLP ingest pipeline, so older SDK
29
+ releases keep working — new integrations use OTLP; the Python sync and async clients both emit OTLP
30
+ too). The OTLP ingest route requires the API
31
+ key to carry **both `sessions:write` and `events:write`** scopes — dashboard-generated keys include
32
+ both, but a manually-scoped `sessions:write`-only key will silently drop data. Add `events:write`
33
+ to the key (see [Transport](#transport)).
34
+ - **Timestamp fields are now `string` (ISO 8601), not `Date`.** The `Session` fields (`createdAt` /
35
+ `updatedAt` / `completedAt`) and `Event.timestamp` were retyped because the API returns JSON and
36
+ `response.json()` never revived them into `Date` objects, so the old `Date` typing was incorrect —
37
+ but this is a compile-breaking change for code like `session.createdAt.getTime()`. Migrate by
38
+ parsing where needed: `new Date(session.createdAt).getTime()`.
39
+ - **`PiiConfig.enhancedDetection` was removed.** The field was a no-op (never read by redaction
40
+ logic) and is gone from `PiiConfig`, mirroring the Python SDK. Drop it from any `PiiConfig`
41
+ literal — it had no effect.
42
+
43
+ Shipped under a minor bump (not a patch), per the SDK compatibility policy.
44
+
45
+ ## CLI
46
+
47
+ The SDK package also ships a `sentrial` binary for scriptable workflows. This is the
48
+ first packaging step toward a dedicated `npm install sentrial` style CLI package.
49
+
50
+ ```bash
51
+ # Install globally
52
+ npm install -g @sentrial/sdk
53
+
54
+ # Or run without installing
55
+ npx -p @sentrial/sdk sentrial sessions list
56
+
57
+ # Save credentials locally
58
+ sentrial login --api-key sentrial_live_xxx
59
+
60
+ # Install the SDK package and scaffold a safe example integration
61
+ sentrial install
62
+
63
+ # Or scaffold only, without installing packages
64
+ sentrial init
65
+
66
+ # Inspect production runs
67
+ sentrial sessions list --last 7d --status failed --agent support
68
+ sentrial sessions get <session-id>
69
+ sentrial events list <session-id> --type tool_call
70
+
71
+ # Review signals and run CI gates
72
+ sentrial signals list --type negative --last 30d
73
+ sentrial diagnose <session-id>
74
+ sentrial evaluate <experiment-id> --min-score 80
75
+ ```
76
+
77
+ > Note: `sentrial evaluate` calls `/api/v1/evaluate`, which requires an API key
78
+ > scoped with `metrics:read`. Make sure the key you use (via `--api-key`,
79
+ > `SENTRIAL_API_KEY`, or `sentrial login`) carries that scope, or the CI gate
80
+ > will fail with a 403.
81
+
21
82
  ## Quick Start (30 seconds)
22
83
 
23
84
  ### Option 1: Auto-Wrap LLM Clients (Recommended)
@@ -65,11 +126,11 @@ const supportAgent = withSession('support-agent', async (userId: string, message
65
126
  ### Option 3: Vercel AI SDK
66
127
 
67
128
  ```typescript
68
- import { configure, wrapAISDK } from '@sentrial/sdk';
129
+ import { configureVercel, wrapAISDK } from '@sentrial/sdk';
69
130
  import * as ai from 'ai';
70
131
  import { openai } from '@ai-sdk/openai';
71
132
 
72
- configure({ apiKey: process.env.SENTRIAL_API_KEY, defaultAgent: 'my-agent' });
133
+ configureVercel({ apiKey: process.env.SENTRIAL_API_KEY });
73
134
 
74
135
  const { generateText, streamText } = wrapAISDK(ai);
75
136
 
@@ -80,6 +141,59 @@ const { text } = await generateText({
80
141
  });
81
142
  ```
82
143
 
144
+ ### Option 4: OpenAI Agents SDK
145
+
146
+ One call registers a trace processor with the Agents SDK (`@openai/agents`, an optional peer
147
+ dependency, supported range `>=0.1.0 <1.0.0`) — every run becomes a Sentrial session with LLM
148
+ events, tool calls, handoffs, and guardrails. Don't combine with `wrapOpenAI()` on the same run
149
+ (LLM calls would be double-counted).
150
+
151
+ ```typescript
152
+ import { Agent, run, addTraceProcessor } from '@openai/agents';
153
+ import { sentrial, instrumentOpenAIAgents } from '@sentrial/sdk';
154
+
155
+ sentrial.configure({ apiKey: process.env.SENTRIAL_API_KEY });
156
+ instrumentOpenAIAgents({ addTraceProcessor }, { userId: 'user_123' });
157
+
158
+ const agent = new Agent({ name: 'Assistant', instructions: 'Be helpful.' });
159
+ const result = await run(agent, 'Write a haiku about recursion.');
160
+ // The run is now a Sentrial session with LLM events, tool calls, handoffs.
161
+ ```
162
+
163
+ See the [OpenAI Agents SDK integration docs](https://www.sentrial.com/docs/integrations/openai-agents)
164
+ for configuration options, the run→session mapping, and batching/flush guidance.
165
+
166
+ ### Option 5: Google ADK
167
+
168
+ Register one Plugin on your Google ADK `Runner` (`@google/adk`, an optional peer dependency,
169
+ supported range `>=0.1.0 <2.0.0`) — every run becomes a Sentrial session with LLM events, tool
170
+ calls, tokens, cost, and errors. The Plugin fires globally across every agent, tool, and model
171
+ call in the invocation. Don't combine with `wrapGoogle()` on the same run (LLM calls would be
172
+ double-counted).
173
+
174
+ ```typescript
175
+ import { InMemoryRunner, LlmAgent } from '@google/adk';
176
+ import { sentrial, SentrialAdkPlugin } from '@sentrial/sdk';
177
+
178
+ sentrial.configure({ apiKey: process.env.SENTRIAL_API_KEY });
179
+
180
+ const agent = new LlmAgent({ name: 'assistant', model: 'gemini-2.0-flash' });
181
+ const runner = new InMemoryRunner({ agent, plugins: [new SentrialAdkPlugin()] });
182
+ // Every runner.runAsync(...) is now a Sentrial session with LLM events, tool calls, and cost.
183
+ ```
184
+
185
+ Already constructed the `Runner`? Attach after the fact — `instrumentAdk()` is idempotent per
186
+ runner:
187
+
188
+ ```typescript
189
+ import { instrumentAdk } from '@sentrial/sdk';
190
+
191
+ instrumentAdk(runner, { userId: 'user_123' });
192
+ ```
193
+
194
+ See the [Google ADK integration docs](https://www.sentrial.com/docs/integrations/google-adk)
195
+ for configuration options, the run→session mapping, and batching/flush guidance.
196
+
83
197
  ## Features
84
198
 
85
199
  ### LLM Auto-Wrappers
@@ -140,7 +254,7 @@ await experiment.run(async (testCase, variant, tracker) => {
140
254
 
141
255
  ### Vercel AI SDK Integration
142
256
 
143
- Full support for Vercel AI SDK v3-v6:
257
+ Full support for Vercel AI SDK v3-v7:
144
258
 
145
259
  ```typescript
146
260
  import { wrapAISDK } from '@sentrial/sdk';
@@ -163,6 +277,8 @@ const { text } = await generateText({
163
277
  });
164
278
  ```
165
279
 
280
+ > **Note:** `zod` is not a direct dependency of `@sentrial/sdk` — it comes transitively from the Vercel AI SDK (`ai`), which you install to use `wrapAISDK`. The `import { z } from 'zod'` above resolves because `ai` pulls `zod` in as a peer.
281
+
166
282
  ## Configuration
167
283
 
168
284
  ### Environment Variables
@@ -172,6 +288,45 @@ SENTRIAL_API_URL=https://api.sentrial.com
172
288
  SENTRIAL_API_KEY=sentrial_live_xxx
173
289
  ```
174
290
 
291
+ ### Optional agent reports
292
+
293
+ An agent can report missing context, a tool problem, a missing capability, or uncertain completion without marking the session failed:
294
+
295
+ ```ts
296
+ await interaction.reportDiagnostic({
297
+ reportId: 'claim-context-1',
298
+ category: 'missing_context',
299
+ state: 'reported',
300
+ description: 'The policy number needed for this claim is missing.',
301
+ });
302
+ // Later, reuse the report ID and set state: 'recovered' with a new description.
303
+ ```
304
+
305
+ The equivalent client method is `client.reportDiagnostic({ sessionId, report })`. Both a problem and its claimed recovery are agent assertions. They do not create a confirmed signal, training label, business outcome, or automatic action. Each call records a separate event; no prior report is overwritten. There are no model calls.
306
+
307
+ Use an opaque report ID (1–128 letters, digits, `.`, `_`, `:`, or `-`, starting with a letter/digit). Descriptions are limited to 4,096 UTF-8 bytes. Optional `relatedSpanIds` contains at most eight distinct, nonzero, lowercase 16-digit OTel span IDs from the current trace. These references are agent-supplied and are not verified or followed automatically. Normal metadata redaction, batching, transport retry and fail-silent behavior apply. A resolved call is not a durable-delivery receipt; use delivery diagnostics and inspect the retained event. Unknown/degraded/finished sessions cannot accept new reports.
308
+
309
+ The updated collector and trace UI display these as **Agent report**, including **Reported recovered**. Older collectors preserve generic event metadata but do not supply the validated report view. Existing signal evidence selectors do not consume reports as authoritative evidence; measuring incremental detection and review noise requires a separately reviewed evaluation.
310
+
311
+ ### Transport
312
+
313
+ The SDK sends telemetry over **OpenTelemetry (OTLP)** to `/api/otel/v1/traces` — this is the sole
314
+ ingestion path for this SDK (it no longer POSTs sessions/events to the legacy `/api/sdk/*` endpoints,
315
+ which remain available server-side as thin compatibility adapters translated onto the OTLP ingest
316
+ pipeline so older SDK releases keep working; the Python sync and async clients both emit OTLP too).
317
+ The OTLP ingest route
318
+ requires the API key to carry **both `sessions:write` and `events:write`** scopes — keys
319
+ generated in the dashboard include both automatically. If you use a manually-scoped key that
320
+ only has `sessions:write`, session creation will 403 and tracking is silently dropped; add
321
+ `events:write` to the key.
322
+
323
+ Events and completion attach to an in-memory span the client holds from
324
+ `createSession()` / `begin()`, so they only apply to sessions **created on the same client
325
+ instance**. If you obtain a session id elsewhere — a session created out-of-band, shared across
326
+ processes, or tracked on a different `SentrialClient` — its events and finalization are skipped
327
+ (the SDK logs a one-time warning). Use the same client instance for the full begin→track→finish
328
+ flow.
329
+
175
330
  ### Programmatic
176
331
 
177
332
  ```typescript
@@ -241,41 +396,227 @@ const cost = calculateOpenAICost({
241
396
 
242
397
  ## What Gets Tracked
243
398
 
244
- | Data | Auto-tracked | Manual |
245
- |------|-------------|--------|
246
- | LLM calls | ✔️ via wrappers | `trackToolCall()` |
247
- | Token usage | ✔️ via wrappers | `promptTokens` param |
248
- | Cost (USD) | ✔️ calculated | `estimatedCost` param |
249
- | Latency | ✔️ always | - |
250
- | Tool calls | ✔️ via `withTool` | `trackToolCall()` |
251
- | Errors | ✔️ always | `trackError()` |
252
- | User ID | ✔️ via session | `userId` param |
253
- | Custom metrics | - | `customMetrics` param |
399
+ | Data | Auto-tracked | Manual |
400
+ | -------------- | ----------------- | --------------------- |
401
+ | LLM calls | ✔️ via wrappers | `trackToolCall()` |
402
+ | Token usage | ✔️ via wrappers | `promptTokens` param |
403
+ | Cost (USD) | ✔️ calculated | `estimatedCost` param |
404
+ | Latency | ✔️ always | - |
405
+ | Tool calls | ✔️ via `withTool` | `trackToolCall()` |
406
+ | Errors | ✔️ always | `trackError()` |
407
+ | User ID | ✔️ via session | `userId` param |
408
+ | Custom metrics | - | `customMetrics` param |
254
409
 
255
410
  ## Framework Compatibility
256
411
 
257
- | Framework | Integration | Status |
258
- |-----------|-------------|--------|
259
- | **Direct OpenAI** | `wrapOpenAI()` | ✔️ |
260
- | **Direct Anthropic** | `wrapAnthropic()` | ✔️ |
261
- | **Direct Gemini** | `wrapGoogle()` | ✔️ |
262
- | **Vercel AI SDK** | `wrapAISDK()` | ✔️ |
263
- | **Express/Fastify** | Decorators | ✔️ |
264
- | **Next.js** | Decorators | ✔️ |
265
- | **Custom agents** | Full API | ✔️ |
412
+ | Framework | Integration | Status |
413
+ | --------------------- | -------------------------- | ------ |
414
+ | **Direct OpenAI** | `wrapOpenAI()` | ✔️ |
415
+ | **Direct Anthropic** | `wrapAnthropic()` | ✔️ |
416
+ | **Direct Gemini** | `wrapGoogle()` | ✔️ |
417
+ | **Vercel AI SDK** | `wrapAISDK()` | ✔️ |
418
+ | **OpenAI Agents SDK** | `instrumentOpenAIAgents()` | ✔️ |
419
+ | **Google ADK** | `instrumentAdk()` | ✔️ |
420
+ | **Express/Fastify** | Decorators | ✔️ |
421
+ | **Next.js** | Decorators | ✔️ |
422
+ | **Custom agents** | Full API | ✔️ |
266
423
 
267
424
  ## Documentation
268
425
 
269
- See the `docs/` folder in the repository:
270
- - `docs/sdks/typescript-sdk.md` - Full SDK reference
271
- - `docs/integrations/vercel-ai-sdk.md` - Vercel AI SDK integration
272
- - `docs/features/experiments.md` - Experiments guide
426
+ - Full reference: [https://sentrial.com/docs](https://sentrial.com/docs)
427
+ - Repository README: the root `README.md` maps every SDK entry point and the ingest flow
428
+ - Framework wrappers live in `src/vercel.ts` (Vercel AI SDK), `src/openai-agents.ts` (OpenAI Agents SDK), `src/google-adk.ts` (Google ADK), `src/wrappers.ts` (OpenAI/Anthropic), and `src/decorators.ts`
273
429
 
274
430
  ## Support
431
+
275
432
  - 💬 [Discord](https://discord.gg/9bMmJCXt)
276
- - 📧 [Email](mailto:neel@sentrial.com)
277
- - 🐛 [GitHub Issues](https://github.com/neelshar/Sentrial/issues)
433
+ - 📧 [Email](mailto:founders@sentrial.com)
434
+ - 🐛 [GitHub Issues](https://github.com/sentrialdev/Sentrial/issues)
435
+
436
+ ## SDK feature parity (TypeScript ↔ Python)
437
+
438
+ Both SDKs share the same core API (`begin`/`finish`, tool & LLM tracking, production
439
+ experiments, provider wrappers). Framework integrations currently differ:
440
+
441
+ | Capability | TypeScript | Python |
442
+ | ----------------------------------------------------- | -------------------------- | ------------------- |
443
+ | Core tracking (begin/finish, tools, LLM, experiments) | ✓ | ✓ |
444
+ | OpenAI / Anthropic / Google wrappers | ✓ | ✓ |
445
+ | Vercel AI SDK helper | ✓ | — (JS-only) |
446
+ | Claude Agent SDK | partial (single-turn) | ✓ (full multi-turn) |
447
+ | OpenTelemetry export | ✓ (default OTLP transport) | ✓ |
448
+ | Langfuse dual-write | — | ✓ |
449
+ | AutoGen / CrewAI / LangChain handlers | — | ✓ |
450
+ | OpenAI Agents SDK trace processor | ✓ | ✓ |
451
+ | Google ADK Runner plugin | ✓ | ✓ |
452
+
453
+ Porting the missing TypeScript integrations (Langfuse, Claude multi-turn) is
454
+ planned; new TS integrations must keep V1 API response shapes backward-compatible.
278
455
 
279
456
  ## License
280
457
 
281
458
  MIT
459
+
460
+ ## Delivery diagnostics and worker handoffs
461
+
462
+ ### Large tool content (opt-in preview)
463
+
464
+ For enabled workspaces, the native SDK can upload serialized tool inputs/outputs larger than 64 KiB directly to private storage and attach a small reference to the tool span. The configured PII policy runs before byte counting, hashing and upload. Enable the policy fields you need; `redactionRevision` labels that policy and does not itself redact data.
465
+
466
+ ```typescript
467
+ const client = new SentrialClient({
468
+ apiKey: process.env.SENTRIAL_API_KEY,
469
+ pii: { enabled: true },
470
+ largeToolContent: {
471
+ enabled: true,
472
+ redactionRevision: 'claims-redaction-v1',
473
+ timeoutMs: 20_000,
474
+ },
475
+ });
476
+ ```
477
+
478
+ This remains disabled by default and requires separately enabled server storage and workspace quotas. It applies to named `tool_call` input/output fields, including calls recorded through the native SDK integrations; arbitrary external OpenTelemetry spans, LLM messages, metadata and state snapshots are not uploaded. The [Python clients](../python-sdk/README.md#large-tool-content-opt-in-preview) implement the same opt-in; hosted acceptance remains pending.
479
+
480
+ Each field retains a prefix of at most 4 KiB of UTF-8 source bytes inline, with a 16 MiB object limit and two simultaneous uploads per client. Excess concurrent fields, quota failures, unavailable storage, cancellation and oversized objects keep the excerpt plus an explicit missing-content marker. Small fields and clients without the opt-in keep their existing inline behavior. Uploads use at most 24 requests and a configurable 1–60 second per-field deadline (20 seconds by default); this does not bound the subsequent normal trace delivery. A lost PUT acknowledgement is reconciled against retained parts before any retry. Recovery is in memory; restarting a process does not resume its SDK upload.
481
+
482
+ `completeSession()` and `flush()` wait for uploads already started before draining their telemetry. `shutdown()` cancels outstanding uploads and drains their fallback events; call `flush()` first when you want to wait for uploads. Do not emit more events after finishing a session. Abrupt process exit cannot guarantee delivery.
483
+
484
+ `getContentUploadDiagnostics()` returns payload-free attempted/submitted/missing/active/pending-event counts, or `null` when disabled. **Submitted is not verified or inspected:** the server must verify stored bytes, and a detector must separately load the relevant content within its evidence budget. A UI preview also does not establish detector coverage. See the [implementation and rollout limits](../../docs/plans/2026-10-17-telemetry-large-content.md).
485
+
486
+ ### Request and delivery diagnostics
487
+
488
+ For request latency and response reports, read `client.getTransportDiagnostics()` (also available without batching). It separates logical requests, retries, in-flight attempts, response outcomes, reported span acceptance and fixed server timing stages. It retains lifetime aggregates plus 32 recent completed attempts, with no payloads or identifiers. Missing values stay unknown; a successful HTTP response is not proof of unique durable writes. [Usage and measurement limits](../../docs/testing/sdk-transport-timing.md).
489
+
490
+ Call finish after the work belonging to a session has completed, then explicitly flush/close before process exit. Native SDK spans carry source sequence numbers and a final count, so missing events can be detected independently of server arrival order. This proves delivery of emitted spans, not that every application action was instrumented. Queues remain in memory and can drop events at capacity; abrupt termination cannot guarantee a flush.
491
+
492
+ Read `client.getDeliveryDiagnostics()` for payload-free batcher counters (`null` when batching is disabled). A `failed` request was not confirmed delivered; it may have reached the server.
493
+
494
+ ```typescript
495
+ import { actionMetadata, resumeHandoffContext } from '@sentrial/sdk';
496
+
497
+ const parent = await client.begin({
498
+ userId: 'example', event: 'workflow',
499
+ metadata: actionMetadata({ actionId: 'order:42' }),
500
+ });
501
+ const carrier = parent.sessionId
502
+ ? await client.handoff({ sessionId: parent.sessionId, actionId: 'order:42' })
503
+ : null;
504
+ // Pass carrier through your job queue, then in the worker:
505
+ const child = await workerClient.begin({
506
+ userId: 'example', event: 'worker', ...(carrier ? resumeHandoffContext(carrier) : {}),
507
+ });
508
+ const metadata = actionMetadata({ actionId: 'order:42', attemptId: 'try-2', retryOfAttemptId: 'try-1' });
509
+ ```
510
+
511
+ Handoff records delegation before returning the carrier and the worker starts a separate trace linked by parent session. The carrier is correlation metadata, not a credential or verified business outcome. Use opaque identifiers, never customer content or secrets. Low-level `createHandoffContext` / `create_handoff_context` only serializes a carrier; it does not record delegation. V2 signal evidence treats detached outcomes as unresolved until their context is supported.
512
+
513
+ ### Account for worker completion
514
+
515
+ The v2 lifecycle adds a dispatch ID and four explicit records. Existing `handoff()` carriers remain supported. Use a fresh UUID per worker execution; preserve it only when retrying delivery of the same dispatch. A retry that executes work again gets a new dispatch ID (and a new `attemptId`, optionally linked with `retryOfAttemptId`). Use opaque identifiers.
516
+
517
+ ```typescript
518
+ // Parent: record intent before sending the carrier through your queue/RPC.
519
+ import { randomUUID } from 'node:crypto';
520
+
521
+ const carrier = await parentClient.dispatchHandoff({
522
+ sessionId: parentSessionId,
523
+ actionId: 'order:42',
524
+ dispatchId: randomUUID(),
525
+ });
526
+
527
+ // Worker: use its own session, initialized from the received carrier.
528
+ const worker = await workerClient.begin({
529
+ userId: 'example', event: 'order-worker', ...resumeHandoffContext(carrier),
530
+ });
531
+ const workerSessionId = worker.getSessionId();
532
+ if (workerSessionId) {
533
+ await workerClient.acceptHandoff(workerSessionId, carrier);
534
+ // Execute the work here. Report the actual execution outcome.
535
+ await workerClient.finishHandoff(workerSessionId, carrier, 'completed');
536
+ }
537
+ await worker.finish({ output: 'Worker finished' });
538
+
539
+ // Parent: only after your application receives the worker's terminal result.
540
+ // Send the worker session ID and actual outcome with that result.
541
+ if (workerSessionId) {
542
+ await parentClient.receiveHandoff(parentSessionId, carrier, workerSessionId, 'completed');
543
+ }
544
+ ```
545
+
546
+ `completed`, `failed` and `cancelled` describe worker execution, not business success. Record `finishHandoff` before closing the worker session and `receiveHandoff` before closing the parent. Do not emit a completion in an unconditional `finally`; crashes, timeouts or lost results must remain unresolved. A telemetry method returning does not prove the queue or collector received anything. Continue using delivery diagnostics and flush/shutdown normally. If telemetry session creation fails, application work should still continue without these optional records.
547
+
548
+ The internal related-evidence reader detects missing phases, duplicate worker claims, conflicting outcomes and invalid local order. It uses SDK producer sequences rather than arrival order or clocks across machines. Captures remain incomplete for definitive signal judgments. See the [handoff verification contract](../../docs/plans/2026-10-09-handoff-lifecycle-delivery.md).
549
+
550
+ ### Report experiment exposure explicitly
551
+
552
+ `getVariant()` and `createSession()` associate an assignment with a session, including
553
+ holdouts. An association does **not** assert that your application used the configuration.
554
+ After using the returned settings, report that usage explicitly:
555
+
556
+ ```typescript
557
+ const assignment = await client.getVariant({
558
+ experimentName: 'support-prompt',
559
+ agentName: 'Support agent',
560
+ userId: 'stable-user-id',
561
+ });
562
+ const sessionId = await client.createSession({
563
+ name: 'Support request',
564
+ agentName: 'Support agent',
565
+ userId: 'stable-user-id',
566
+ });
567
+ // Apply the returned settings in your application before recording exposure.
568
+ if (sessionId && assignment.assignmentId && assignment.configurationHash) {
569
+ const receipt = await client.recordExperimentExposure({
570
+ sessionId,
571
+ assignmentId: assignment.assignmentId,
572
+ variant: assignment.variant,
573
+ configurationHash: assignment.configurationHash,
574
+ });
575
+ // null means delivery failed under failSilently:true; retry these same fields.
576
+ }
577
+ ```
578
+
579
+ Only report when your application used that configuration. Do not gate holdout reporting
580
+ on `assignment.active`: a holdout has `active:false` and `reason:'holdout'`. Historical
581
+ assignments may lack `configurationHash` and cannot produce a verified matching receipt.
582
+ The digest identifies the frozen returned configuration, not a model provider's behavior.
583
+
584
+ Receipts are idempotent per assignment/session; `recordedAt` is server arrival time.
585
+ Conflicting configuration reports return 409. Deleted source sessions return 410, with
586
+ history retained as tombstones. Delayed reports for previously issued assignments may
587
+ arrive after the experiment stops. The method flushes queued telemetry before sending;
588
+ it does not run inference. `failSilently:false` propagates delivery errors.
589
+
590
+ For overlapping sessions on one client, pass `sessionId` to `getVariant()` or pass
591
+ `experimentAssignmentIds` explicitly when creating a session. Automatic linking targets
592
+ the next session and should only be used when that matches your application's ordering.
593
+ Each experiment/session may belong to only one assignment identity.
594
+
595
+ ### Assignment identity and telemetry acknowledgment
596
+
597
+ An experiment's declared assignment unit is required: supply `userId`/`user_id` for
598
+ user assignment, `convoId`/`convo_id` for conversation assignment, or
599
+ `anonymousId`/`anonymous_id` for anonymous assignment. Another identifier or a session
600
+ ID will not substitute for it. Missing or whitespace-only identity returns
601
+ `active:false`, `reason:'missing_identity'`, the required `assignmentUnit`, and your
602
+ default variant. No assignment is created. Preserve a stable identifier across requests.
603
+ Pass that same user or conversation identifier when creating the session. A conflicting
604
+ session identifier rejects the link; once linked, it cannot be changed or cleared.
605
+ Assignments from different experiments must also agree on each shared assignment unit.
606
+ An anonymous assignment has no corresponding session identity field, so its consistency
607
+ is checked against the session's other anonymous assignments. This does not verify that
608
+ the caller's identifier represents a real person or remains stable outside this session.
609
+
610
+ New assignments report `identityProvenance:'declared'` and their immutable
611
+ `assignmentUnit`. Existing assignments report `identityProvenance:'legacy_unknown'`
612
+ when their historical fallback identity cannot be established. A lookup does not upgrade
613
+ that provenance. Start a new experiment for a prospectively defined identity/analysis plan.
614
+
615
+ Native telemetry commits a session's assignment links in the same transaction as its
616
+ session/events. A failed link fails the request, and a retry preserves the original trace
617
+ identity. Unknown or foreign assignments cannot be silently skipped. Explicit assignment
618
+ lists must contain at most 64 UUIDs; malformed or oversized lists receive HTTP 400 before
619
+ any trace in that batch writes. UUIDs are deduplicated without dropping distinct entries.
620
+ A valid list whose assignment was deleted or is unavailable fails instead of returning a
621
+ false success; correct the references before retrying a permanent error. The existing SDK
622
+ fail-silent/strict error policy still applies.