@telemetry-dev/bedrock 0.1.9 → 0.1.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telemetry-dev/bedrock",
3
- "version": "0.1.9",
3
+ "version": "0.1.12",
4
4
  "description": "AWS Bedrock instrumentation for telemetry.dev: Converse, InvokeModel, and Bedrock Agents spans for AWS SDK v3 clients.",
5
5
  "keywords": [
6
6
  "aws",
@@ -44,12 +44,12 @@
44
44
  "@aws-sdk/client-bedrock-agent-runtime": "3.1135.0",
45
45
  "@aws-sdk/client-bedrock-runtime": "3.1135.0",
46
46
  "@opentelemetry/api": "^1.9.1",
47
- "@opentelemetry/sdk-logs": "^0.218.0",
48
- "@opentelemetry/sdk-trace-base": "^2.7.1",
47
+ "@opentelemetry/sdk-logs": "^0.223.0",
48
+ "@opentelemetry/sdk-trace-base": "^2.12.0",
49
49
  "@smithy/eventstream-codec": "^4.2.5",
50
50
  "@smithy/protocol-http": "^5.3.10",
51
51
  "@smithy/types": "^4.12.0",
52
- "@telemetry-dev/sdk": "^0.1.7",
52
+ "@telemetry-dev/sdk": "^0.1.10",
53
53
  "@types/node": "^25.5.0",
54
54
  "@typescript/native-preview": "7.0.0-dev.20260328.1",
55
55
  "typescript": "^6.0.2",
@@ -59,7 +59,7 @@
59
59
  "peerDependencies": {
60
60
  "@aws-sdk/client-bedrock-agent-runtime": ">=3.600.0 <4",
61
61
  "@aws-sdk/client-bedrock-runtime": ">=3.600.0 <4",
62
- "@telemetry-dev/sdk": "^0.1.7"
62
+ "@telemetry-dev/sdk": "^0.1.10"
63
63
  },
64
64
  "peerDependenciesMeta": {
65
65
  "@aws-sdk/client-bedrock-agent-runtime": {
@@ -5,6 +5,7 @@ import {
5
5
  awsMetadataFields,
6
6
  basenameArn,
7
7
  bytesToString,
8
+ captureMaskedBeforeStart,
8
9
  isRecord,
9
10
  mergeFields,
10
11
  modeledStreamError,
@@ -12,6 +13,7 @@ import {
12
13
  omitUndefined,
13
14
  PROVIDER,
14
15
  stringValue,
16
+ StreamRetention,
15
17
  type BedrockInstrumentationOptions,
16
18
  type JsonRecord,
17
19
  type JsonValue,
@@ -48,17 +50,30 @@ export function inlineAgentRequestFields(input: JsonRecord): SpanFields {
48
50
  }
49
51
 
50
52
  export class AgentStreamState implements StreamState {
53
+ readonly retention = new StreamRetention();
51
54
  private text = "";
55
+ // Trace and output-file metadata share the budget but do not make the text incomplete.
56
+ private textRejected = false;
52
57
  private usage: NonNullable<SpanFields["usage"]> = {};
53
58
  private traceEventCount = 0;
59
+ private failed = false;
60
+ private failureCode: number | undefined;
54
61
  private failureReason: string | undefined;
55
62
  private guardrailAction: string | undefined;
56
63
  private returnControl: unknown;
64
+ private sawReturnControl = false;
65
+ private returnControlRejected = false;
57
66
  private readonly traces: unknown[] = [];
58
67
  private error: Error | undefined;
59
68
  private errorFields: SpanFields | undefined;
60
69
  private citationCount = 0;
61
70
  private readonly outputFiles: unknown[] = [];
71
+ // Metadata bypasses the capture flags and the mask, so trace and file content is filtered here,
72
+ // and a mask that cannot see it withholds it.
73
+ private readonly contentCapture: AgentTraceCapture = {
74
+ input: captureEnabled("input") && !captureMaskedBeforeStart(),
75
+ output: captureEnabled("output") && !captureMaskedBeforeStart(),
76
+ };
62
77
 
63
78
  constructor(private readonly options: BedrockInstrumentationOptions) {}
64
79
 
@@ -78,7 +93,13 @@ export class AgentStreamState implements StreamState {
78
93
  const chunk = isRecord(raw.chunk) ? raw.chunk : undefined;
79
94
  const bytes = chunk?.bytes;
80
95
 
81
- if (bytes !== undefined) this.text += bytesToString(bytes) ?? "";
96
+ const piece = bytes === undefined ? undefined : bytesToString(bytes);
97
+
98
+ if (piece) {
99
+ if (this.retention.accept(piece)) this.text += piece;
100
+ else this.textRejected = true;
101
+ }
102
+
82
103
  const attribution = isRecord(chunk?.attribution) ? chunk.attribution : undefined;
83
104
  const citations = arrayValue<unknown>(attribution?.citations);
84
105
 
@@ -86,36 +107,86 @@ export class AgentStreamState implements StreamState {
86
107
 
87
108
  const files = isRecord(raw.files) ? arrayValue<JsonValue>(raw.files.files) : undefined;
88
109
 
89
- if (files) this.outputFiles.push(...files.map(sanitizeOutputFile));
110
+ const sanitizedFiles = files?.map((file) =>
111
+ sanitizeOutputFile(file, this.contentCapture.output),
112
+ );
113
+
114
+ if (sanitizedFiles && this.retention.accept(sanitizedFiles)) {
115
+ this.outputFiles.push(...sanitizedFiles);
116
+ }
90
117
 
91
118
  const returnControl = isRecord(raw.returnControl) ? raw.returnControl : undefined;
92
119
 
93
- if (returnControl) this.returnControl = returnControl;
120
+ if (returnControl) {
121
+ this.sawReturnControl = true;
122
+
123
+ // Return control replaces the text output, so it gets its own fresh bound and survives
124
+ // text that already spent the shared budget; one that alone does not fit is dropped and
125
+ // the capture is marked incomplete.
126
+ if (new StreamRetention().accept(returnControl)) {
127
+ this.returnControl = returnControl;
128
+ } else {
129
+ this.returnControl = undefined;
130
+ this.returnControlRejected = true;
131
+ this.retention.truncated = true;
132
+ }
133
+ }
94
134
 
95
135
  const traceEvent = isRecord(raw.trace) ? raw.trace : undefined;
96
136
 
97
137
  if (traceEvent) {
98
138
  this.traceEventCount += 1;
99
139
 
100
- if (this.options.captureAgentTrace) this.traces.push(traceEvent);
101
- collectTrace(traceEvent, (usage) => this.addUsage(usage));
102
- const failure = findKey(traceEvent, "failureTrace");
140
+ if (this.options.captureAgentTrace) {
141
+ const retained = projectTrace(traceEvent, TRACE_PART, this.contentCapture);
142
+
143
+ if (this.retention.accept(retained)) this.traces.push(retained);
144
+ }
103
145
 
104
- if (isRecord(failure))
105
- this.failureReason = stringValue(failure.failureReason) ?? this.failureReason;
106
- const guardrail = findKey(traceEvent, "guardrailTrace");
146
+ // Summaries are read only where the API puts them, so content and unknown branches can't
147
+ // feed them.
148
+ const trace = isRecord(traceEvent.trace) ? traceEvent.trace : {};
149
+
150
+ for (const step of MODEL_INVOCATION_STEPS) {
151
+ const member = isRecord(trace[step]) ? trace[step] : undefined;
152
+ const output = isRecord(member?.modelInvocationOutput) ? member.modelInvocationOutput : {};
153
+ this.addUsage(isRecord(output.metadata) ? output.metadata.usage : undefined);
154
+ }
155
+
156
+ const failure = trace.failureTrace;
157
+
158
+ if (isRecord(failure)) {
159
+ this.failed = true;
160
+ this.failureCode = numberValue(failure.failureCode) ?? this.failureCode;
161
+
162
+ // The reason is free-form text that can quote the prompt or a tool result.
163
+ if (this.contentCapture.input && this.contentCapture.output) {
164
+ this.failureReason = stringValue(failure.failureReason) ?? this.failureReason;
165
+ }
166
+ }
167
+
168
+ const guardrail = trace.guardrailTrace;
107
169
 
108
170
  if (isRecord(guardrail))
109
171
  this.guardrailAction = stringValue(guardrail.action) ?? this.guardrailAction;
110
172
  }
111
173
  }
112
174
 
175
+ outputTruncated(): boolean {
176
+ // Return control replaces the text output and is retained whole when it fits its bound.
177
+ if (this.returnControl !== undefined) return false;
178
+
179
+ return this.textRejected || this.returnControlRejected;
180
+ }
181
+
113
182
  finish(): SpanFields {
114
183
  const metadata = omitUndefined({
115
184
  trace_event_count: this.traceEventCount || undefined,
185
+ failure: this.failed ? true : undefined,
186
+ failure_code: this.failureCode,
116
187
  failure_reason: this.failureReason,
117
188
  guardrail_action: this.guardrailAction,
118
- return_control: this.returnControl ? true : undefined,
189
+ return_control: this.sawReturnControl ? true : undefined,
119
190
  citation_count: this.citationCount || undefined,
120
191
  output_files: this.outputFiles.length > 0 ? this.outputFiles : undefined,
121
192
  agent_trace:
@@ -158,13 +229,13 @@ export class AgentStreamState implements StreamState {
158
229
  }
159
230
  }
160
231
 
161
- function sanitizeOutputFile<T>(value: T): JsonValue {
232
+ function sanitizeOutputFile<T>(value: T, keepName: boolean): JsonValue {
162
233
  const raw: unknown = value;
163
234
 
164
235
  if (!isRecord(raw)) return undefined;
165
236
 
166
237
  return omitUndefined({
167
- name: stringValue(raw.name),
238
+ name: keepName ? stringValue(raw.name) : undefined,
168
239
  type: stringValue(raw.type),
169
240
  });
170
241
  }
@@ -303,6 +374,7 @@ export function ragResponseFields<T>(output: T): SpanFields {
303
374
  }
304
375
 
305
376
  export class RagStreamState implements StreamState {
377
+ readonly retention = new StreamRetention();
306
378
  private text = "";
307
379
  private citationCount = 0;
308
380
  private guardrailAction: string | undefined;
@@ -323,7 +395,9 @@ export class RagStreamState implements StreamState {
323
395
  }
324
396
 
325
397
  const output = isRecord(raw.output) ? raw.output : undefined;
326
- this.text += stringValue(output?.text) ?? "";
398
+ const text = stringValue(output?.text);
399
+
400
+ if (text && this.retention.accept(text)) this.text += text;
327
401
 
328
402
  if (raw.citation) this.citationCount += 1;
329
403
  const guardrail = isRecord(raw.guardrail) ? raw.guardrail : undefined;
@@ -356,6 +430,7 @@ export function flowRequestFields(input: JsonRecord): SpanFields {
356
430
  }
357
431
 
358
432
  export class FlowStreamState implements StreamState {
433
+ readonly retention = new StreamRetention();
359
434
  private outputs: unknown[] = [];
360
435
  private completionReason: string | undefined;
361
436
  private error: Error | undefined;
@@ -376,13 +451,16 @@ export class FlowStreamState implements StreamState {
376
451
 
377
452
  const output = isRecord(raw.flowOutputEvent) ? raw.flowOutputEvent : undefined;
378
453
 
379
- if (output) this.outputs.push(output.content);
454
+ if (output && this.retention.accept(output.content)) this.outputs.push(output.content);
380
455
 
381
456
  const inputRequest = isRecord(raw.flowMultiTurnInputRequestEvent)
382
457
  ? raw.flowMultiTurnInputRequestEvent
383
458
  : undefined;
384
459
 
385
- if (inputRequest) this.outputs.push(inputRequest.content);
460
+ if (inputRequest && this.retention.accept(inputRequest.content)) {
461
+ this.outputs.push(inputRequest.content);
462
+ }
463
+
386
464
  const completion = isRecord(raw.flowCompletionEvent) ? raw.flowCompletionEvent : undefined;
387
465
  this.completionReason = stringValue(completion?.completionReason) ?? this.completionReason;
388
466
  }
@@ -407,35 +485,135 @@ export function ragName(input: JsonRecord): string {
407
485
  return `retrieve_and_generate ${basenameArn(ragModel(input)) ?? "knowledge-base"}`;
408
486
  }
409
487
 
410
- function collectTrace<T>(value: T, onUsage: <TUsage>(usage: TUsage) => void): void {
411
- const raw: unknown = value;
488
+ type AgentTraceCapture = Record<"input" | "output", boolean>;
412
489
 
413
- if (!isRecord(raw)) return;
414
- const output = isRecord(raw.modelInvocationOutput) ? raw.modelInvocationOutput : undefined;
490
+ /** Content kept as received when every listed capture is on; otherwise reduced to `withheld`. */
491
+ class TraceContent {
492
+ constructor(
493
+ readonly needs: ReadonlyArray<keyof AgentTraceCapture>,
494
+ readonly withheld?: TraceShape,
495
+ ) {}
496
+ }
415
497
 
416
- if (output) {
417
- const metadata = isRecord(output.metadata) ? output.metadata : undefined;
418
- onUsage(metadata?.usage);
419
- }
498
+ const SCALAR = "scalar";
499
+
500
+ // A record shape lists the only fields kept; a one-item array shape is a list of that shape.
501
+ type TraceShape =
502
+ | typeof SCALAR
503
+ | TraceContent
504
+ | readonly [TraceShape]
505
+ | { readonly [field: string]: TraceShape };
506
+
507
+ const TRACE_METADATA: TraceShape = {
508
+ usage: { inputTokens: SCALAR, outputTokens: SCALAR },
509
+ startTime: SCALAR,
510
+ endTime: SCALAR,
511
+ totalTimeMs: SCALAR,
512
+ operationTotalTimeMs: SCALAR,
513
+ clientRequestId: SCALAR,
514
+ };
515
+
516
+ // What the agent sends to a model, tool, or guardrail is input content; what comes back is output.
517
+ const MODEL_INVOCATION_INPUT = new TraceContent(["input"], {
518
+ traceId: SCALAR,
519
+ type: SCALAR,
520
+ foundationModel: SCALAR,
521
+ });
522
+
523
+ const INVOCATION_INPUT = new TraceContent(["input"], { traceId: SCALAR, invocationType: SCALAR });
524
+
525
+ const MODEL_INVOCATION_OUTPUT = new TraceContent(["output"], {
526
+ traceId: SCALAR,
527
+ metadata: TRACE_METADATA,
528
+ });
529
+
530
+ const OBSERVATION = new TraceContent(["output"], { traceId: SCALAR, type: SCALAR });
531
+
532
+ const MODEL_INVOCATION_STEPS = [
533
+ "preProcessingTrace",
534
+ "orchestrationTrace",
535
+ "postProcessingTrace",
536
+ "routingClassifierTrace",
537
+ ];
538
+
539
+ const PROCESSING_TRACE: TraceShape = {
540
+ modelInvocationInput: MODEL_INVOCATION_INPUT,
541
+ modelInvocationOutput: MODEL_INVOCATION_OUTPUT,
542
+ };
543
+
544
+ // Mirrors TracePart and the Trace union in the Bedrock Agent Runtime API; anything else, including
545
+ // the AWS SDK's `$unknown` union member, is dropped because nothing says what it holds.
546
+ const TRACE_PART: TraceShape = {
547
+ agentId: SCALAR,
548
+ agentAliasId: SCALAR,
549
+ agentVersion: SCALAR,
550
+ sessionId: SCALAR,
551
+ collaboratorName: SCALAR,
552
+ eventTime: SCALAR,
553
+ callerChain: [{ agentAliasArn: SCALAR }],
554
+ trace: {
555
+ preProcessingTrace: PROCESSING_TRACE,
556
+ postProcessingTrace: PROCESSING_TRACE,
557
+ orchestrationTrace: {
558
+ modelInvocationInput: MODEL_INVOCATION_INPUT,
559
+ invocationInput: INVOCATION_INPUT,
560
+ modelInvocationOutput: MODEL_INVOCATION_OUTPUT,
561
+ rationale: new TraceContent(["output"], { traceId: SCALAR }),
562
+ observation: OBSERVATION,
563
+ },
564
+ routingClassifierTrace: {
565
+ modelInvocationInput: MODEL_INVOCATION_INPUT,
566
+ invocationInput: INVOCATION_INPUT,
567
+ modelInvocationOutput: MODEL_INVOCATION_OUTPUT,
568
+ observation: OBSERVATION,
569
+ },
570
+ customOrchestrationTrace: { traceId: SCALAR, event: new TraceContent(["output"]) },
571
+ guardrailTrace: {
572
+ traceId: SCALAR,
573
+ action: SCALAR,
574
+ metadata: TRACE_METADATA,
575
+ inputAssessments: new TraceContent(["input"]),
576
+ outputAssessments: new TraceContent(["output"]),
577
+ },
578
+ failureTrace: {
579
+ traceId: SCALAR,
580
+ failureCode: SCALAR,
581
+ metadata: TRACE_METADATA,
582
+ // Free-form text that can quote either the prompt or a tool result.
583
+ failureReason: new TraceContent(["input", "output"]),
584
+ },
585
+ },
586
+ };
420
587
 
421
- for (const child of Object.values(raw)) {
422
- if (isRecord(child)) collectTrace(child, onUsage);
423
- else if (Array.isArray(child)) child.forEach((item) => collectTrace(item, onUsage));
424
- }
425
- }
588
+ function projectTrace(value: JsonValue, shape: TraceShape, capture: AgentTraceCapture): JsonValue {
589
+ if (shape instanceof TraceContent) {
590
+ if (shape.needs.every((kind) => capture[kind])) return value ?? undefined;
426
591
 
427
- function findKey<T>(value: T, key: string): JsonValue {
428
- const raw: unknown = value;
592
+ return shape.withheld === undefined ? undefined : projectTrace(value, shape.withheld, capture);
593
+ }
429
594
 
430
- if (!isRecord(raw)) return undefined;
595
+ if (shape === SCALAR) {
596
+ const scalar = ["string", "number", "boolean"].includes(typeof value) || value instanceof Date;
431
597
 
432
- if (raw[key] !== undefined) return raw[key];
598
+ return scalar ? value : undefined;
599
+ }
433
600
 
434
- for (const child of Object.values(raw)) {
435
- const found = findKey(child, key);
601
+ if (Array.isArray(shape)) {
602
+ if (!Array.isArray(value)) return undefined;
436
603
 
437
- if (found !== undefined) return found;
604
+ return value
605
+ .map((item) => projectTrace(item, shape[0], capture))
606
+ .filter((item) => item !== undefined);
438
607
  }
439
608
 
440
- return undefined;
609
+ if (!isRecord(value)) return undefined;
610
+
611
+ return omitUndefined(
612
+ Object.fromEntries(
613
+ Object.entries(shape).map(([field, child]) => [
614
+ field,
615
+ projectTrace(value[field], child, capture),
616
+ ]),
617
+ ),
618
+ );
441
619
  }
package/src/converse.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  parseJson,
12
12
  PROVIDER,
13
13
  stringValue,
14
+ StreamRetention,
14
15
  type JsonRecord,
15
16
  type JsonValue,
16
17
  type StreamState,
@@ -256,6 +257,7 @@ interface StreamBlock {
256
257
  }
257
258
 
258
259
  export class ConverseStreamState implements StreamState {
260
+ readonly retention = new StreamRetention();
259
261
  private role = "assistant";
260
262
  private readonly blocks = new Map<number, StreamBlock>();
261
263
  private stopReason: string | undefined;
@@ -282,7 +284,7 @@ export class ConverseStreamState implements StreamState {
282
284
 
283
285
  const blockStart = isRecord(event.contentBlockStart) ? event.contentBlockStart : undefined;
284
286
 
285
- if (blockStart) {
287
+ if (blockStart && this.retention.accept(blockStart.start ?? {})) {
286
288
  const index = numberValue(blockStart.contentBlockIndex) ?? this.blocks.size;
287
289
  const start = isRecord(blockStart.start) ? blockStart.start : undefined;
288
290
  const toolUse = isRecord(start?.toolUse) ? start?.toolUse : undefined;
@@ -316,9 +318,10 @@ export class ConverseStreamState implements StreamState {
316
318
 
317
319
  const deltaEvent = isRecord(event.contentBlockDelta) ? event.contentBlockDelta : undefined;
318
320
 
319
- if (deltaEvent) {
321
+ const delta = isRecord(deltaEvent?.delta) ? deltaEvent.delta : {};
322
+
323
+ if (deltaEvent && this.retention.accept(delta)) {
320
324
  const index = numberValue(deltaEvent.contentBlockIndex) ?? 0;
321
- const delta = isRecord(deltaEvent.delta) ? deltaEvent.delta : {};
322
325
  const block = this.blocks.get(index) ?? { kind: "text", text: "" };
323
326
  const text = stringValue(delta.text);
324
327
 
@@ -32,6 +32,7 @@ import {
32
32
  stringValue,
33
33
  wrapAsyncIterable,
34
34
  endSpan,
35
+ endSpanWithBoundedOutput,
35
36
  type BedrockInstrumentationOptions,
36
37
  } from "./internal.ts";
37
38
 
@@ -154,7 +155,7 @@ export const AGENT_HANDLERS = {
154
155
  spanType: () => "span",
155
156
  requestFields: rerankRequestFields,
156
157
  onResult(result, span) {
157
- endSpan(span, rerankResponseFields(result));
158
+ endSpanWithBoundedOutput(span, rerankResponseFields(result));
158
159
 
159
160
  return result;
160
161
  },
package/src/internal.ts CHANGED
@@ -1,4 +1,12 @@
1
- import { startSpan, type SpanFields, type SpanHandle, type SpanType } from "@telemetry-dev/sdk";
1
+ import {
2
+ boundedCaptureDetails,
3
+ startSpan,
4
+ type SpanFields,
5
+ type SpanHandle,
6
+ type SpanType,
7
+ type StartSpanOptions,
8
+ } from "@telemetry-dev/sdk";
9
+ import * as telemetrySdk from "@telemetry-dev/sdk";
2
10
 
3
11
  export const PROVIDER = "amazon-bedrock";
4
12
 
@@ -64,7 +72,7 @@ export function createInstrumentedSend(
64
72
  const spanType = safe(() => handler.spanType(input)) ?? "span";
65
73
  const spanName = safe(() => handler.spanName(input)) ?? name ?? "bedrock";
66
74
  const fields = safe(() => handler.requestFields(input, options)) ?? {};
67
- const span = startSpan(spanName, { type: spanType, ...fields });
75
+ const span = startRequestSpan(spanName, { type: spanType, ...fields });
68
76
  const callbackIndex = rawRest.findIndex((arg) => arg instanceof Function);
69
77
 
70
78
  if (callbackIndex !== -1) {
@@ -162,6 +170,42 @@ function withCacheInclusiveInput(fields: SpanFields | undefined): SpanFields | u
162
170
  return { ...fields, usage: { ...usage, inputTokens, totalTokens } };
163
171
  }
164
172
 
173
+ // A capture cut to the retention bound keeps its truncated flag, but a mask cannot be trusted to
174
+ // redact a value it only sees part of. Cores without a capture policy are treated as masked.
175
+ function capturesWithMask(span: SpanHandle): boolean {
176
+ return span.capturePolicy?.mask ?? true;
177
+ }
178
+
179
+ function isCut(fields: SpanFields | undefined): boolean {
180
+ return fields?.attributes?.["telemetry.dev.capture.truncated"] === true;
181
+ }
182
+
183
+ // Cores older than captureMasked expose no mask policy before a span starts; treat them as
184
+ // masked, like LEGACY_CAPTURE_POLICY. A namespace read keeps them loadable without the export.
185
+ export function captureMaskedBeforeStart(): boolean {
186
+ const read = (telemetrySdk as { captureMasked?: () => boolean }).captureMasked;
187
+
188
+ try {
189
+ return typeof read === "function" ? read() : true;
190
+ } catch {
191
+ return true;
192
+ }
193
+ }
194
+
195
+ // The decision is made before the span starts so samplers see the same attributes as before.
196
+ function startRequestSpan(name: string, options: StartSpanOptions): SpanHandle {
197
+ const withheld = options.input !== undefined && isCut(options) && captureMaskedBeforeStart();
198
+
199
+ return startSpan(name, withheld ? { ...options, input: undefined } : options);
200
+ }
201
+
202
+ /** Ends a non-streamed span, withholding output cut to the retention bound from a mask. */
203
+ export function endSpanWithBoundedOutput(span: SpanHandle, fields: SpanFields): void {
204
+ const withheld = fields.output !== undefined && isCut(fields) && capturesWithMask(span);
205
+
206
+ endSpan(span, withheld ? { ...fields, output: undefined } : fields);
207
+ }
208
+
165
209
  export function endSpan(span: SpanHandle, fields?: SpanFields): void {
166
210
  try {
167
211
  span.end(withCacheInclusiveInput(fields));
@@ -306,9 +350,69 @@ export function parseJson<T>(value: T): JsonValue {
306
350
  }
307
351
  }
308
352
 
353
+ // Retention uses fixed bounds; the core applies maxAttributeLength after the mask.
354
+ const STREAM_CAPTURE_MAX_BYTES = 48 * 1024;
355
+ export const STREAM_CAPTURE_MAX_ITEMS = 1_000;
356
+ const CAPTURE_TRUNCATED_ATTRIBUTE = "telemetry.dev.capture.truncated";
357
+
358
+ // Older cores enforce captureOutput and masking when the span ends but expose no policy, so
359
+ // withhold incomplete output from their mask. Retention uses the fixed stream bounds either way;
360
+ // maxAttributeLength is only here to satisfy the policy type.
361
+ const LEGACY_CAPTURE_POLICY: NonNullable<SpanHandle["capturePolicy"]> = {
362
+ output: true,
363
+ mask: true,
364
+ maxAttributeLength: 65_536,
365
+ };
366
+
367
+ /** Tracks the fixed byte and item bound before a stream state retains a value. */
368
+ export class StreamRetention {
369
+ private bytes = 0;
370
+ private items = 0;
371
+ truncated = false;
372
+
373
+ accept<T>(value: T): boolean {
374
+ if (this.truncated) return false;
375
+
376
+ const measured = boundedCaptureDetails(value, {
377
+ maxBytes: STREAM_CAPTURE_MAX_BYTES - this.bytes,
378
+ maxItems: STREAM_CAPTURE_MAX_ITEMS - this.items,
379
+ });
380
+
381
+ if (measured.truncated) {
382
+ this.truncated = true;
383
+
384
+ return false;
385
+ }
386
+
387
+ this.bytes += measured.bytes;
388
+ this.items += measured.items;
389
+
390
+ return true;
391
+ }
392
+ }
393
+
309
394
  export interface StreamState {
310
395
  feed<T>(event: T): boolean | void;
311
396
  finish(partial: boolean): SpanFields;
397
+ readonly retention: StreamRetention;
398
+ /** Whether the exported output itself was cut; defaults to the retention flag. */
399
+ outputTruncated?(): boolean;
400
+ }
401
+
402
+ function finishStream(state: StreamState, span: SpanHandle, partial: boolean): SpanFields {
403
+ const fields = state.finish(partial);
404
+ const interrupted = partial || fields.error !== undefined;
405
+
406
+ if (!interrupted && !state.retention.truncated) return fields;
407
+
408
+ fields.attributes = { ...fields.attributes, [CAPTURE_TRUNCATED_ATTRIBUTE]: true };
409
+ const outputTruncated = state.outputTruncated?.() ?? state.retention.truncated;
410
+
411
+ if ((span.capturePolicy ?? LEGACY_CAPTURE_POLICY).mask && (interrupted || outputTruncated)) {
412
+ delete fields.output;
413
+ }
414
+
415
+ return fields;
312
416
  }
313
417
 
314
418
  export function wrapAsyncIterable<T>(
@@ -360,13 +464,13 @@ export function wrapAsyncIterable<T>(
360
464
  completed = true;
361
465
  } catch (error) {
362
466
  endOnce(
363
- mergeFields(state.finish(true), awsMetadataFields(metadataOf(error)), {
467
+ mergeFields(finishStream(state, span, true), awsMetadataFields(metadataOf(error)), {
364
468
  error: asError(error),
365
469
  }),
366
470
  );
367
471
  throw error;
368
472
  } finally {
369
- if (!ended) endOnce(state.finish(!completed));
473
+ if (!ended) endOnce(finishStream(state, span, !completed));
370
474
  }
371
475
  };
372
476
 
@@ -378,7 +482,7 @@ export function wrapAsyncIterable<T>(
378
482
  }
379
483
  }
380
484
 
381
- const finishAbandoned = () => endOnce(safe(() => state.finish(true)));
485
+ const finishAbandoned = () => endOnce(safe(() => finishStream(state, span, true)));
382
486
  wrapped[Symbol.asyncIterator] = () => {
383
487
  const iterator = generator();
384
488
  STREAM_FINALIZER?.register(iterator, finishAbandoned, unregisterToken);