@telemetry-dev/bedrock 0.1.9 → 0.1.10

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
@@ -138,8 +138,11 @@ InvokeModel captures provider-native JSON bodies verbatim. Non-JSON bodies are n
138
138
  - Instrumentation is fail-open. Normalization, span updates, and span endings are guarded so telemetry failures do not break caller code.
139
139
  - Requests are never modified. This is especially important for AWS SigV4 signing and for application-level request object reuse.
140
140
  - Streams are pull-through. The wrapper does not pre-buffer chunks and therefore preserves backpressure.
141
- - Streams end spans exactly once. Exhaustion records full output and finish reason; early `break`, `return()`, `close()`, abort, or stream errors record partial output.
141
+ - Streams end spans exactly once. Exhaustion records full output and finish reason; early `break`, `return()`, `close()`, abort, or stream errors record partial output and set `telemetry.dev.capture.truncated`.
142
+ - Stream reconstruction (output, Agent trace events, and Agent output-file metadata) retains up to 48 KiB and 1,000 items; events past the bound still pass through to the caller. The configured `maxAttributeLength` does not shrink this bound; the core applies it to the exported attribute after the mask runs.
143
+ - A stream cut at the bound, stopped early, or failed sets `telemetry.dev.capture.truncated`. With a mask configured, its incomplete reconstructed output is omitted because the mask cannot inspect the complete value. Agent trace events and output-file metadata share the bound, but dropping them only sets the attribute; Agent text is omitted only when a text chunk was dropped or the stream stopped early or failed. A normally exhausted Agent stream that returned control keeps its complete `returnControl` output even if earlier text or metadata passed the bound; an early stop or failure still omits the output when a mask is configured.
142
144
  - `AbortSignal` passed to `send(command, { abortSignal })` is forwarded untouched. Pre-response aborts end the span with the thrown abort error. Mid-stream aborts end the span with partial output and the stream error.
145
+ - `Rerank` input and results are bounded to 48 KiB and 1,000 items. A value over the bound sets `telemetry.dev.capture.truncated`. Without a mask, the bounded part is kept. With a mask configured, that value is omitted because the mask cannot inspect the complete value. Input is omitted before the span starts, so a custom `sampler` sees the bounded input when no mask is configured; with an older `@telemetry-dev/sdk` that cannot report whether a mask is configured, cut input is omitted as if one were.
143
146
  - Callback-style `send(command, callback)` and `send(command, options, callback)` calls are instrumented; callbacks receive the original data/error while the span is ended before callback invocation.
144
147
 
145
148
  ## Coverage gaps
package/dist/agents.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { r as createInstrumentedSend, t as AGENT_HANDLERS } from "./internal-handlers-Byxp14OZ.mjs";
1
+ import { r as createInstrumentedSend, t as AGENT_HANDLERS } from "./internal-handlers-PftQAPH3.mjs";
2
2
  import { BedrockAgentRuntimeClient } from "@aws-sdk/client-bedrock-agent-runtime";
3
3
  //#region src/agents.ts
4
4
  const WRAPPED = /* @__PURE__ */ new WeakSet();
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { n as RUNTIME_HANDLERS, r as createInstrumentedSend } from "./internal-handlers-Byxp14OZ.mjs";
1
+ import { n as RUNTIME_HANDLERS, r as createInstrumentedSend } from "./internal-handlers-PftQAPH3.mjs";
2
2
  import { BedrockRuntimeClient } from "@aws-sdk/client-bedrock-runtime";
3
3
  //#region src/index.ts
4
4
  const WRAPPED = /* @__PURE__ */ new WeakSet();
@@ -1,4 +1,5 @@
1
- import { boundedCapture, captureEnabled, startSpan } from "@telemetry-dev/sdk";
1
+ import * as telemetrySdk from "@telemetry-dev/sdk";
2
+ import { boundedCapture, boundedCaptureDetails, captureEnabled, startSpan } from "@telemetry-dev/sdk";
2
3
  //#region src/internal.ts
3
4
  const PROVIDER = "amazon-bedrock";
4
5
  const STREAM_FINALIZER = typeof FinalizationRegistry === "function" ? new FinalizationRegistry((finish) => finish()) : void 0;
@@ -12,7 +13,7 @@ function createInstrumentedSend(originalSend, options, handlers) {
12
13
  const input = commandInput(rawCommand);
13
14
  const t0 = performance.now();
14
15
  const spanType = safe(() => handler.spanType(input)) ?? "span";
15
- const span = startSpan(safe(() => handler.spanName(input)) ?? name ?? "bedrock", {
16
+ const span = startRequestSpan(safe(() => handler.spanName(input)) ?? name ?? "bedrock", {
16
17
  type: spanType,
17
18
  ...safe(() => handler.requestFields(input, options)) ?? {}
18
19
  });
@@ -103,6 +104,33 @@ function withCacheInclusiveInput(fields) {
103
104
  }
104
105
  };
105
106
  }
107
+ function capturesWithMask(span) {
108
+ return span.capturePolicy?.mask ?? true;
109
+ }
110
+ function isCut(fields) {
111
+ return fields?.attributes?.["telemetry.dev.capture.truncated"] === true;
112
+ }
113
+ function captureMaskedBeforeStart() {
114
+ const read = telemetrySdk.captureMasked;
115
+ try {
116
+ return typeof read === "function" ? read() : true;
117
+ } catch {
118
+ return true;
119
+ }
120
+ }
121
+ function startRequestSpan(name, options) {
122
+ return startSpan(name, options.input !== void 0 && isCut(options) && captureMaskedBeforeStart() ? {
123
+ ...options,
124
+ input: void 0
125
+ } : options);
126
+ }
127
+ /** Ends a non-streamed span, withholding output cut to the retention bound from a mask. */
128
+ function endSpanWithBoundedOutput(span, fields) {
129
+ endSpan(span, fields.output !== void 0 && isCut(fields) && capturesWithMask(span) ? {
130
+ ...fields,
131
+ output: void 0
132
+ } : fields);
133
+ }
106
134
  function endSpan(span, fields) {
107
135
  try {
108
136
  span.end(withCacheInclusiveInput(fields));
@@ -209,6 +237,46 @@ function parseJson(value) {
209
237
  return;
210
238
  }
211
239
  }
240
+ const STREAM_CAPTURE_MAX_BYTES = 48 * 1024;
241
+ const STREAM_CAPTURE_MAX_ITEMS = 1e3;
242
+ const CAPTURE_TRUNCATED_ATTRIBUTE = "telemetry.dev.capture.truncated";
243
+ const LEGACY_CAPTURE_POLICY = {
244
+ output: true,
245
+ mask: true,
246
+ maxAttributeLength: 65536
247
+ };
248
+ /** Tracks the fixed byte and item bound before a stream state retains a value. */
249
+ var StreamRetention = class {
250
+ bytes = 0;
251
+ items = 0;
252
+ truncated = false;
253
+ accept(value) {
254
+ if (this.truncated) return false;
255
+ const measured = boundedCaptureDetails(value, {
256
+ maxBytes: STREAM_CAPTURE_MAX_BYTES - this.bytes,
257
+ maxItems: STREAM_CAPTURE_MAX_ITEMS - this.items
258
+ });
259
+ if (measured.truncated) {
260
+ this.truncated = true;
261
+ return false;
262
+ }
263
+ this.bytes += measured.bytes;
264
+ this.items += measured.items;
265
+ return true;
266
+ }
267
+ };
268
+ function finishStream(state, span, partial) {
269
+ const fields = state.finish(partial);
270
+ const interrupted = partial || fields.error !== void 0;
271
+ if (!interrupted && !state.retention.truncated) return fields;
272
+ fields.attributes = {
273
+ ...fields.attributes,
274
+ [CAPTURE_TRUNCATED_ATTRIBUTE]: true
275
+ };
276
+ const outputTruncated = state.outputTruncated?.() ?? state.retention.truncated;
277
+ if ((span.capturePolicy ?? LEGACY_CAPTURE_POLICY).mask && (interrupted || outputTruncated)) delete fields.output;
278
+ return fields;
279
+ }
212
280
  function wrapAsyncIterable(iterable, state, span, t0, baseFields, trackOutputChunks = true) {
213
281
  if (!iterable || !isAsyncIterable(iterable)) {
214
282
  endSpan(span, baseFields);
@@ -238,15 +306,15 @@ function wrapAsyncIterable(iterable, state, span, t0, baseFields, trackOutputChu
238
306
  }
239
307
  completed = true;
240
308
  } catch (error) {
241
- endOnce(mergeFields(state.finish(true), awsMetadataFields(metadataOf(error)), { error: asError(error) }));
309
+ endOnce(mergeFields(finishStream(state, span, true), awsMetadataFields(metadataOf(error)), { error: asError(error) }));
242
310
  throw error;
243
311
  } finally {
244
- if (!ended) endOnce(state.finish(!completed));
312
+ if (!ended) endOnce(finishStream(state, span, !completed));
245
313
  }
246
314
  };
247
315
  const wrapped = {};
248
316
  if (isRecord(iterable)) for (const key of Reflect.ownKeys(iterable)) wrapped[key] = iterable[key];
249
- const finishAbandoned = () => endOnce(safe(() => state.finish(true)));
317
+ const finishAbandoned = () => endOnce(safe(() => finishStream(state, span, true)));
250
318
  wrapped[Symbol.asyncIterator] = () => {
251
319
  const iterator = generator();
252
320
  STREAM_FINALIZER?.register(iterator, finishAbandoned, unregisterToken);
@@ -340,12 +408,16 @@ function inlineAgentRequestFields(input) {
340
408
  });
341
409
  }
342
410
  var AgentStreamState = class {
411
+ retention = new StreamRetention();
343
412
  text = "";
413
+ textRejected = false;
344
414
  usage = {};
345
415
  traceEventCount = 0;
346
416
  failureReason;
347
417
  guardrailAction;
348
418
  returnControl;
419
+ sawReturnControl = false;
420
+ returnControlRejected = false;
349
421
  traces = [];
350
422
  error;
351
423
  errorFields;
@@ -365,17 +437,27 @@ var AgentStreamState = class {
365
437
  }
366
438
  const chunk = isRecord(raw.chunk) ? raw.chunk : void 0;
367
439
  const bytes = chunk?.bytes;
368
- if (bytes !== void 0) this.text += bytesToString(bytes) ?? "";
440
+ const piece = bytes === void 0 ? void 0 : bytesToString(bytes);
441
+ if (piece) if (this.retention.accept(piece)) this.text += piece;
442
+ else this.textRejected = true;
369
443
  const citations = arrayValue((isRecord(chunk?.attribution) ? chunk.attribution : void 0)?.citations);
370
444
  if (citations) this.citationCount += citations.length;
371
- const files = isRecord(raw.files) ? arrayValue(raw.files.files) : void 0;
372
- if (files) this.outputFiles.push(...files.map(sanitizeOutputFile));
445
+ const sanitizedFiles = (isRecord(raw.files) ? arrayValue(raw.files.files) : void 0)?.map(sanitizeOutputFile);
446
+ if (sanitizedFiles && this.retention.accept(sanitizedFiles)) this.outputFiles.push(...sanitizedFiles);
373
447
  const returnControl = isRecord(raw.returnControl) ? raw.returnControl : void 0;
374
- if (returnControl) this.returnControl = returnControl;
448
+ if (returnControl) {
449
+ this.sawReturnControl = true;
450
+ if (new StreamRetention().accept(returnControl)) this.returnControl = returnControl;
451
+ else {
452
+ this.returnControl = void 0;
453
+ this.returnControlRejected = true;
454
+ this.retention.truncated = true;
455
+ }
456
+ }
375
457
  const traceEvent = isRecord(raw.trace) ? raw.trace : void 0;
376
458
  if (traceEvent) {
377
459
  this.traceEventCount += 1;
378
- if (this.options.captureAgentTrace) this.traces.push(traceEvent);
460
+ if (this.options.captureAgentTrace && this.retention.accept(traceEvent)) this.traces.push(traceEvent);
379
461
  collectTrace(traceEvent, (usage) => this.addUsage(usage));
380
462
  const failure = findKey(traceEvent, "failureTrace");
381
463
  if (isRecord(failure)) this.failureReason = stringValue(failure.failureReason) ?? this.failureReason;
@@ -383,12 +465,16 @@ var AgentStreamState = class {
383
465
  if (isRecord(guardrail)) this.guardrailAction = stringValue(guardrail.action) ?? this.guardrailAction;
384
466
  }
385
467
  }
468
+ outputTruncated() {
469
+ if (this.returnControl !== void 0) return false;
470
+ return this.textRejected || this.returnControlRejected;
471
+ }
386
472
  finish() {
387
473
  const metadata = omitUndefined({
388
474
  trace_event_count: this.traceEventCount || void 0,
389
475
  failure_reason: this.failureReason,
390
476
  guardrail_action: this.guardrailAction,
391
- return_control: this.returnControl ? true : void 0,
477
+ return_control: this.sawReturnControl ? true : void 0,
392
478
  citation_count: this.citationCount || void 0,
393
479
  output_files: this.outputFiles.length > 0 ? this.outputFiles : void 0,
394
480
  agent_trace: this.options.captureAgentTrace && this.traces.length > 0 ? this.traces : void 0
@@ -533,6 +619,7 @@ function ragResponseFields(output) {
533
619
  });
534
620
  }
535
621
  var RagStreamState = class {
622
+ retention = new StreamRetention();
536
623
  text = "";
537
624
  citationCount = 0;
538
625
  guardrailAction;
@@ -547,8 +634,8 @@ var RagStreamState = class {
547
634
  this.errorFields = streamError.fields;
548
635
  return;
549
636
  }
550
- const output = isRecord(raw.output) ? raw.output : void 0;
551
- this.text += stringValue(output?.text) ?? "";
637
+ const text = stringValue((isRecord(raw.output) ? raw.output : void 0)?.text);
638
+ if (text && this.retention.accept(text)) this.text += text;
552
639
  if (raw.citation) this.citationCount += 1;
553
640
  const guardrail = isRecord(raw.guardrail) ? raw.guardrail : void 0;
554
641
  this.guardrailAction = stringValue(guardrail?.action) ?? this.guardrailAction;
@@ -578,6 +665,7 @@ function flowRequestFields(input) {
578
665
  });
579
666
  }
580
667
  var FlowStreamState = class {
668
+ retention = new StreamRetention();
581
669
  outputs = [];
582
670
  completionReason;
583
671
  error;
@@ -592,9 +680,9 @@ var FlowStreamState = class {
592
680
  return;
593
681
  }
594
682
  const output = isRecord(raw.flowOutputEvent) ? raw.flowOutputEvent : void 0;
595
- if (output) this.outputs.push(output.content);
683
+ if (output && this.retention.accept(output.content)) this.outputs.push(output.content);
596
684
  const inputRequest = isRecord(raw.flowMultiTurnInputRequestEvent) ? raw.flowMultiTurnInputRequestEvent : void 0;
597
- if (inputRequest) this.outputs.push(inputRequest.content);
685
+ if (inputRequest && this.retention.accept(inputRequest.content)) this.outputs.push(inputRequest.content);
598
686
  const completion = isRecord(raw.flowCompletionEvent) ? raw.flowCompletionEvent : void 0;
599
687
  this.completionReason = stringValue(completion?.completionReason) ?? this.completionReason;
600
688
  }
@@ -801,6 +889,7 @@ function s3Uri(value) {
801
889
  return bucket && key ? `s3://${bucket}/${key}` : void 0;
802
890
  }
803
891
  var ConverseStreamState = class {
892
+ retention = new StreamRetention();
804
893
  role = "assistant";
805
894
  blocks = /* @__PURE__ */ new Map();
806
895
  stopReason;
@@ -820,7 +909,7 @@ var ConverseStreamState = class {
820
909
  const messageStart = isRecord(event.messageStart) ? event.messageStart : void 0;
821
910
  if (messageStart) this.role = stringValue(messageStart.role) ?? this.role;
822
911
  const blockStart = isRecord(event.contentBlockStart) ? event.contentBlockStart : void 0;
823
- if (blockStart) {
912
+ if (blockStart && this.retention.accept(blockStart.start ?? {})) {
824
913
  const index = numberValue(blockStart.contentBlockIndex) ?? this.blocks.size;
825
914
  const start = isRecord(blockStart.start) ? blockStart.start : void 0;
826
915
  const toolUse = isRecord(start?.toolUse) ? start?.toolUse : void 0;
@@ -847,9 +936,9 @@ var ConverseStreamState = class {
847
936
  });
848
937
  }
849
938
  const deltaEvent = isRecord(event.contentBlockDelta) ? event.contentBlockDelta : void 0;
850
- if (deltaEvent) {
939
+ const delta = isRecord(deltaEvent?.delta) ? deltaEvent.delta : {};
940
+ if (deltaEvent && this.retention.accept(delta)) {
851
941
  const index = numberValue(deltaEvent.contentBlockIndex) ?? 0;
852
- const delta = isRecord(deltaEvent.delta) ? deltaEvent.delta : {};
853
942
  const block = this.blocks.get(index) ?? {
854
943
  kind: "text",
855
944
  text: ""
@@ -1109,6 +1198,7 @@ function headerNumber(value) {
1109
1198
  return numberValue(value);
1110
1199
  }
1111
1200
  var InvokeModelStreamState = class {
1201
+ retention = new StreamRetention();
1112
1202
  chunks = [];
1113
1203
  text = "";
1114
1204
  usage;
@@ -1129,8 +1219,10 @@ var InvokeModelStreamState = class {
1129
1219
  const raw = chunk?.bytes;
1130
1220
  return (typeof raw === "string" || raw instanceof Uint8Array) && raw.length > 0;
1131
1221
  }
1132
- this.chunks.push(parsed);
1133
- this.text += textFromProviderChunk(parsed);
1222
+ if (this.retention.accept(parsed)) {
1223
+ this.chunks.push(parsed);
1224
+ this.text += textFromProviderChunk(parsed);
1225
+ }
1134
1226
  if (isRecord(parsed)) {
1135
1227
  const delta = isRecord(parsed.delta) ? parsed.delta : void 0;
1136
1228
  const message = isRecord(parsed.message) ? parsed.message : void 0;
@@ -1278,7 +1370,7 @@ const AGENT_HANDLERS = {
1278
1370
  spanType: () => "span",
1279
1371
  requestFields: rerankRequestFields,
1280
1372
  onResult(result, span) {
1281
- endSpan(span, rerankResponseFields(result));
1373
+ endSpanWithBoundedOutput(span, rerankResponseFields(result));
1282
1374
  return result;
1283
1375
  }
1284
1376
  },
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.10",
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",
@@ -49,7 +49,7 @@
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.8",
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.8"
63
63
  },
64
64
  "peerDependenciesMeta": {
65
65
  "@aws-sdk/client-bedrock-agent-runtime": {
@@ -12,6 +12,7 @@ import {
12
12
  omitUndefined,
13
13
  PROVIDER,
14
14
  stringValue,
15
+ StreamRetention,
15
16
  type BedrockInstrumentationOptions,
16
17
  type JsonRecord,
17
18
  type JsonValue,
@@ -48,12 +49,17 @@ export function inlineAgentRequestFields(input: JsonRecord): SpanFields {
48
49
  }
49
50
 
50
51
  export class AgentStreamState implements StreamState {
52
+ readonly retention = new StreamRetention();
51
53
  private text = "";
54
+ // Trace and output-file metadata share the budget but do not make the text incomplete.
55
+ private textRejected = false;
52
56
  private usage: NonNullable<SpanFields["usage"]> = {};
53
57
  private traceEventCount = 0;
54
58
  private failureReason: string | undefined;
55
59
  private guardrailAction: string | undefined;
56
60
  private returnControl: unknown;
61
+ private sawReturnControl = false;
62
+ private returnControlRejected = false;
57
63
  private readonly traces: unknown[] = [];
58
64
  private error: Error | undefined;
59
65
  private errorFields: SpanFields | undefined;
@@ -78,7 +84,12 @@ export class AgentStreamState implements StreamState {
78
84
  const chunk = isRecord(raw.chunk) ? raw.chunk : undefined;
79
85
  const bytes = chunk?.bytes;
80
86
 
81
- if (bytes !== undefined) this.text += bytesToString(bytes) ?? "";
87
+ const piece = bytes === undefined ? undefined : bytesToString(bytes);
88
+
89
+ if (piece) {
90
+ if (this.retention.accept(piece)) this.text += piece;
91
+ else this.textRejected = true;
92
+ }
82
93
  const attribution = isRecord(chunk?.attribution) ? chunk.attribution : undefined;
83
94
  const citations = arrayValue<unknown>(attribution?.citations);
84
95
 
@@ -86,18 +97,37 @@ export class AgentStreamState implements StreamState {
86
97
 
87
98
  const files = isRecord(raw.files) ? arrayValue<JsonValue>(raw.files.files) : undefined;
88
99
 
89
- if (files) this.outputFiles.push(...files.map(sanitizeOutputFile));
100
+ const sanitizedFiles = files?.map(sanitizeOutputFile);
101
+
102
+ if (sanitizedFiles && this.retention.accept(sanitizedFiles)) {
103
+ this.outputFiles.push(...sanitizedFiles);
104
+ }
90
105
 
91
106
  const returnControl = isRecord(raw.returnControl) ? raw.returnControl : undefined;
92
107
 
93
- if (returnControl) this.returnControl = returnControl;
108
+ if (returnControl) {
109
+ this.sawReturnControl = true;
110
+
111
+ // Return control replaces the text output, so it gets its own fresh bound and survives
112
+ // text that already spent the shared budget; one that alone does not fit is dropped and
113
+ // the capture is marked incomplete.
114
+ if (new StreamRetention().accept(returnControl)) {
115
+ this.returnControl = returnControl;
116
+ } else {
117
+ this.returnControl = undefined;
118
+ this.returnControlRejected = true;
119
+ this.retention.truncated = true;
120
+ }
121
+ }
94
122
 
95
123
  const traceEvent = isRecord(raw.trace) ? raw.trace : undefined;
96
124
 
97
125
  if (traceEvent) {
98
126
  this.traceEventCount += 1;
99
127
 
100
- if (this.options.captureAgentTrace) this.traces.push(traceEvent);
128
+ if (this.options.captureAgentTrace && this.retention.accept(traceEvent)) {
129
+ this.traces.push(traceEvent);
130
+ }
101
131
  collectTrace(traceEvent, (usage) => this.addUsage(usage));
102
132
  const failure = findKey(traceEvent, "failureTrace");
103
133
 
@@ -110,12 +140,19 @@ export class AgentStreamState implements StreamState {
110
140
  }
111
141
  }
112
142
 
143
+ outputTruncated(): boolean {
144
+ // Return control replaces the text output and is retained whole when it fits its bound.
145
+ if (this.returnControl !== undefined) return false;
146
+
147
+ return this.textRejected || this.returnControlRejected;
148
+ }
149
+
113
150
  finish(): SpanFields {
114
151
  const metadata = omitUndefined({
115
152
  trace_event_count: this.traceEventCount || undefined,
116
153
  failure_reason: this.failureReason,
117
154
  guardrail_action: this.guardrailAction,
118
- return_control: this.returnControl ? true : undefined,
155
+ return_control: this.sawReturnControl ? true : undefined,
119
156
  citation_count: this.citationCount || undefined,
120
157
  output_files: this.outputFiles.length > 0 ? this.outputFiles : undefined,
121
158
  agent_trace:
@@ -303,6 +340,7 @@ export function ragResponseFields<T>(output: T): SpanFields {
303
340
  }
304
341
 
305
342
  export class RagStreamState implements StreamState {
343
+ readonly retention = new StreamRetention();
306
344
  private text = "";
307
345
  private citationCount = 0;
308
346
  private guardrailAction: string | undefined;
@@ -323,7 +361,9 @@ export class RagStreamState implements StreamState {
323
361
  }
324
362
 
325
363
  const output = isRecord(raw.output) ? raw.output : undefined;
326
- this.text += stringValue(output?.text) ?? "";
364
+ const text = stringValue(output?.text);
365
+
366
+ if (text && this.retention.accept(text)) this.text += text;
327
367
 
328
368
  if (raw.citation) this.citationCount += 1;
329
369
  const guardrail = isRecord(raw.guardrail) ? raw.guardrail : undefined;
@@ -356,6 +396,7 @@ export function flowRequestFields(input: JsonRecord): SpanFields {
356
396
  }
357
397
 
358
398
  export class FlowStreamState implements StreamState {
399
+ readonly retention = new StreamRetention();
359
400
  private outputs: unknown[] = [];
360
401
  private completionReason: string | undefined;
361
402
  private error: Error | undefined;
@@ -376,13 +417,15 @@ export class FlowStreamState implements StreamState {
376
417
 
377
418
  const output = isRecord(raw.flowOutputEvent) ? raw.flowOutputEvent : undefined;
378
419
 
379
- if (output) this.outputs.push(output.content);
420
+ if (output && this.retention.accept(output.content)) this.outputs.push(output.content);
380
421
 
381
422
  const inputRequest = isRecord(raw.flowMultiTurnInputRequestEvent)
382
423
  ? raw.flowMultiTurnInputRequestEvent
383
424
  : undefined;
384
425
 
385
- if (inputRequest) this.outputs.push(inputRequest.content);
426
+ if (inputRequest && this.retention.accept(inputRequest.content)) {
427
+ this.outputs.push(inputRequest.content);
428
+ }
386
429
  const completion = isRecord(raw.flowCompletionEvent) ? raw.flowCompletionEvent : undefined;
387
430
  this.completionReason = stringValue(completion?.completionReason) ?? this.completionReason;
388
431
  }
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
+ 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
+ export 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);
@@ -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,
@@ -251,6 +252,7 @@ function headerNumber<T>(value: T): number | undefined {
251
252
  }
252
253
 
253
254
  export class InvokeModelStreamState implements StreamState {
255
+ readonly retention = new StreamRetention();
254
256
  private readonly chunks: unknown[] = [];
255
257
  private text = "";
256
258
  private usage: SpanFields["usage"];
@@ -277,8 +279,10 @@ export class InvokeModelStreamState implements StreamState {
277
279
  return (typeof raw === "string" || raw instanceof Uint8Array) && raw.length > 0;
278
280
  }
279
281
 
280
- this.chunks.push(parsed);
281
- this.text += textFromProviderChunk(parsed);
282
+ if (this.retention.accept(parsed)) {
283
+ this.chunks.push(parsed);
284
+ this.text += textFromProviderChunk(parsed);
285
+ }
282
286
 
283
287
  if (isRecord(parsed)) {
284
288
  const delta = isRecord(parsed.delta) ? parsed.delta : undefined;