@warlock.js/ai-panoptic 4.3.0 → 4.4.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +9 -1
  2. package/cjs/index.cjs +259 -52
  3. package/cjs/index.cjs.map +1 -1
  4. package/esm/collector/collector.d.mts +2 -1
  5. package/esm/collector/collector.d.mts.map +1 -1
  6. package/esm/collector/collector.mjs +10 -4
  7. package/esm/collector/collector.mjs.map +1 -1
  8. package/esm/collector/content-capture.type.d.mts +42 -0
  9. package/esm/collector/content-capture.type.d.mts.map +1 -0
  10. package/esm/collector/index.d.mts +1 -0
  11. package/esm/collector/report-to-span.d.mts +8 -1
  12. package/esm/collector/report-to-span.d.mts.map +1 -1
  13. package/esm/collector/report-to-span.mjs +66 -2
  14. package/esm/collector/report-to-span.mjs.map +1 -1
  15. package/esm/collector/report-to-trace.d.mts +2 -1
  16. package/esm/collector/report-to-trace.d.mts.map +1 -1
  17. package/esm/collector/report-to-trace.mjs +2 -2
  18. package/esm/collector/report-to-trace.mjs.map +1 -1
  19. package/esm/contracts/trace.type.d.mts +22 -3
  20. package/esm/contracts/trace.type.d.mts.map +1 -1
  21. package/esm/exporters/console/console-exporter.d.mts +11 -3
  22. package/esm/exporters/console/console-exporter.d.mts.map +1 -1
  23. package/esm/exporters/console/console-exporter.mjs +31 -13
  24. package/esm/exporters/console/console-exporter.mjs.map +1 -1
  25. package/esm/exporters/console/console-exporter.type.d.mts +15 -0
  26. package/esm/exporters/console/console-exporter.type.d.mts.map +1 -1
  27. package/esm/exporters/console/format-span-io.d.mts +27 -0
  28. package/esm/exporters/console/format-span-io.d.mts.map +1 -0
  29. package/esm/exporters/console/format-span-io.mjs +52 -0
  30. package/esm/exporters/console/format-span-io.mjs.map +1 -0
  31. package/esm/exporters/console/index.d.mts +2 -1
  32. package/esm/exporters/index.d.mts +1 -0
  33. package/esm/exporters/index.mjs +1 -0
  34. package/esm/exporters/langfuse/langfuse-exporter.d.mts +10 -6
  35. package/esm/exporters/langfuse/langfuse-exporter.d.mts.map +1 -1
  36. package/esm/exporters/langfuse/langfuse-exporter.mjs +77 -30
  37. package/esm/exporters/langfuse/langfuse-exporter.mjs.map +1 -1
  38. package/esm/exporters/langfuse/langfuse-exporter.type.d.mts +3 -1
  39. package/esm/exporters/langfuse/langfuse-exporter.type.d.mts.map +1 -1
  40. package/esm/exporters/otel/otel-exporter.mjs +15 -0
  41. package/esm/exporters/otel/otel-exporter.mjs.map +1 -1
  42. package/esm/exporters/utils/gen-ai-attributes.d.mts +3 -1
  43. package/esm/exporters/utils/gen-ai-attributes.d.mts.map +1 -1
  44. package/esm/exporters/utils/gen-ai-attributes.mjs +5 -1
  45. package/esm/exporters/utils/gen-ai-attributes.mjs.map +1 -1
  46. package/esm/index.d.mts +3 -1
  47. package/esm/index.mjs +2 -1
  48. package/esm/panoptic/panoptic.d.mts.map +1 -1
  49. package/esm/panoptic/panoptic.mjs +4 -1
  50. package/esm/panoptic/panoptic.mjs.map +1 -1
  51. package/esm/panoptic/panoptic.type.d.mts +17 -0
  52. package/esm/panoptic/panoptic.type.d.mts.map +1 -1
  53. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -4,7 +4,15 @@ All notable changes to `@warlock.js/ai-panoptic` are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
6
6
 
7
- ## [Unreleased]
7
+ ## 4.4.0 - 2026-06-21
8
+
9
+ ### Added
10
+
11
+ - **Opt-in content capture.** `createCollector` / `panoptic({ captureContent, redactContent })` copies the agent prompt/response (first-trip input, last non-empty trip output) and each tool's args/result onto `TraceSpan.input` / `output`. Surfaced by `consoleExporter({ io, ioMaxChars })` (new `in:` / `out:` lines), the file exporter's JSON, OTel `gen_ai.prompt` / `gen_ai.completion`, and Langfuse native `input` / `output`. Off by default — payloads are large and often sensitive; a `ContentRedactor` masks each value. New exports: `ContentCaptureOptions`, `ContentRedactor`, `formatSpanIO`.
12
+
13
+ ### Fixed
14
+
15
+ - **Langfuse token accounting.** Generations now meter their OWN usage (rolled-up minus children); composite nodes with no own tokens emit as plain spans; the root execution is now emitted as an observation (its own tokens were previously unmetered); and the rolled-up usage/cost is stripped from observation metadata (the `usage` block is authoritative). Together these stop the trace total from double-counting nested spans.
8
16
 
9
17
  ## 4.3.0 - 2026-06-21
10
18
 
package/cjs/index.cjs CHANGED
@@ -159,6 +159,12 @@ function addToolAttributes(attributes, extensions) {
159
159
  * identity, tool trip index) is routed into the optional `attributes`
160
160
  * bag via {@link extractSpanAttributes}.
161
161
  *
162
+ * When {@link ContentCaptureOptions.captureContent} is set, the raw
163
+ * prompt/response (agents) and args/result (tools) are additionally
164
+ * copied onto `span.input` / `span.output` — off by default because
165
+ * payloads are large and often sensitive. A {@link ContentRedactor} can
166
+ * mask each value first.
167
+ *
162
168
  * No external lookup is needed — a `BaseReport` already carries
163
169
  * everything a span requires, so a collector can flatten a tree without
164
170
  * consulting any other source.
@@ -167,7 +173,7 @@ function addToolAttributes(attributes, extensions) {
167
173
  * const root = reportToSpan(result.report);
168
174
  * console.log(root.spanId, root.traceId, root.children.length);
169
175
  */
170
- function reportToSpan(report) {
176
+ function reportToSpan(report, options) {
171
177
  const span = {
172
178
  spanId: report.runId,
173
179
  traceId: report.rootRunId,
@@ -178,7 +184,7 @@ function reportToSpan(report) {
178
184
  endedAt: report.endedAt,
179
185
  duration: report.duration,
180
186
  usage: report.usage,
181
- children: report.children.map(reportToSpan)
187
+ children: report.children.map((child) => reportToSpan(child, options))
182
188
  };
183
189
  if (report.parentRunId !== void 0) span.parentSpanId = report.parentRunId;
184
190
  if (report.sessionId !== void 0) span.sessionId = report.sessionId;
@@ -187,8 +193,66 @@ function reportToSpan(report) {
187
193
  if (error !== void 0) span.error = error;
188
194
  const attributes = extractSpanAttributes(report);
189
195
  if (attributes !== void 0) span.attributes = attributes;
196
+ if (options?.captureContent) captureContent(span, report, options.redactContent);
190
197
  return span;
191
198
  }
199
+ /**
200
+ * Copy the node's raw content onto `span.input` / `span.output`.
201
+ *
202
+ * - Tools carry the call arguments + return value directly on the report
203
+ * (`ToolCall.input` / `ToolCall.output`).
204
+ * - Agents carry a `trips[]` history; the first trip's `input` is the
205
+ * real user prompt, and the last NON-EMPTY trip `output` is the final
206
+ * response text. (A failed / max-trips run can end on a trip whose
207
+ * `output` is `""` or tool-call-only, so we scan back for the last one
208
+ * that carried text rather than surfacing an empty string. Intermediate
209
+ * trips also store a `"[tool results]"` placeholder upstream.)
210
+ *
211
+ * Each value is passed through the optional {@link ContentRedactor};
212
+ * a redactor returning `undefined` drops the field.
213
+ */
214
+ function captureContent(span, report, redact) {
215
+ const node = report;
216
+ let input;
217
+ let output;
218
+ if (report.type === "tool") {
219
+ input = node.input;
220
+ output = node.output;
221
+ } else if (Array.isArray(node.trips) && node.trips.length > 0) {
222
+ input = node.trips[0]?.input;
223
+ output = lastNonEmptyOutput(node.trips);
224
+ }
225
+ if (input !== void 0) {
226
+ const value = redact ? redact(input, {
227
+ name: span.name,
228
+ type: span.type,
229
+ field: "input"
230
+ }) : input;
231
+ if (value !== void 0) span.input = value;
232
+ }
233
+ if (output !== void 0) {
234
+ const value = redact ? redact(output, {
235
+ name: span.name,
236
+ type: span.type,
237
+ field: "output"
238
+ }) : output;
239
+ if (value !== void 0) span.output = value;
240
+ }
241
+ }
242
+ /**
243
+ * The last trip output that actually carries text — the agent's final
244
+ * response on the happy path. A failed or max-trips run can end on a trip
245
+ * whose `output` is `""` or tool-call-only, so we scan backwards for the
246
+ * last trip that produced text rather than blindly taking the final trip
247
+ * (which would surface an empty string). Returns `undefined` when no trip
248
+ * produced any output.
249
+ */
250
+ function lastNonEmptyOutput(trips) {
251
+ for (let i = trips.length - 1; i >= 0; i -= 1) {
252
+ const out = trips[i]?.output;
253
+ if (typeof out === "string" ? out.length > 0 : out !== void 0) return out;
254
+ }
255
+ }
192
256
 
193
257
  //#endregion
194
258
  //#region ../@warlock.js/ai-panoptic/src/collector/report-to-trace.ts
@@ -219,8 +283,8 @@ function reportToSpan(report) {
219
283
  * const trace = reportToTrace(result.report, result.error);
220
284
  * console.log(trace.traceId, trace.usage.total, trace.duration);
221
285
  */
222
- function reportToTrace(report, rootError) {
223
- const root = reportToSpan(report);
286
+ function reportToTrace(report, rootError, options) {
287
+ const root = reportToSpan(report, options);
224
288
  if (root.error === void 0) {
225
289
  const error = normalizeError(rootError);
226
290
  if (error !== void 0) root.error = error;
@@ -257,7 +321,13 @@ function reportToTrace(report, rootError) {
257
321
  * agent loop nor stop sibling exporters from receiving the trace.
258
322
  */
259
323
  var Collector = class {
260
- constructor() {
324
+ /**
325
+ * Content-capture options threaded into every `toTrace` projection.
326
+ * When `captureContent` is set, projected spans carry `input` /
327
+ * `output`; otherwise they stay metadata-only.
328
+ */
329
+ constructor(options = {}) {
330
+ this.options = options;
261
331
  this.exporters = /* @__PURE__ */ new Map();
262
332
  }
263
333
  use(exporter) {
@@ -265,7 +335,7 @@ var Collector = class {
265
335
  return this;
266
336
  }
267
337
  toTrace(report, rootError) {
268
- return reportToTrace(report, rootError);
338
+ return reportToTrace(report, rootError, this.options);
269
339
  }
270
340
  async collect(report, rootError) {
271
341
  const trace = this.toTrace(report, rootError);
@@ -318,8 +388,8 @@ var Collector = class {
318
388
  * // on shutdown:
319
389
  * await collector.shutdown();
320
390
  */
321
- function createCollector() {
322
- return new Collector();
391
+ function createCollector(options = {}) {
392
+ return new Collector(options);
323
393
  }
324
394
 
325
395
  //#endregion
@@ -592,7 +662,11 @@ const GEN_AI_ATTRIBUTES = {
592
662
  responseModel: "gen_ai.response.model",
593
663
  usageInputTokens: "gen_ai.usage.input_tokens",
594
664
  usageOutputTokens: "gen_ai.usage.output_tokens",
595
- conversationId: "gen_ai.conversation.id"
665
+ conversationId: "gen_ai.conversation.id",
666
+ /** Captured prompt/input (set only under content capture). */
667
+ prompt: "gen_ai.prompt",
668
+ /** Captured completion/output (set only under content capture). */
669
+ completion: "gen_ai.completion"
596
670
  };
597
671
  /**
598
672
  * Panoptic-specific attribute keys that have no GenAI-convention
@@ -659,6 +733,56 @@ function mergeScalarAttributes(target, source) {
659
733
  for (const [key, value] of Object.entries(source)) if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") target[key] = value;
660
734
  }
661
735
 
736
+ //#endregion
737
+ //#region ../@warlock.js/ai-panoptic/src/exporters/console/format-span-io.ts
738
+ /** Default cap on how many characters of captured content a console line shows. */
739
+ const DEFAULT_IO_MAX_CHARS = 500;
740
+ /**
741
+ * Render a span's captured content ({@link TraceSpan.input} /
742
+ * {@link TraceSpan.output}) as extra indented console lines, one level
743
+ * below the span's own line:
744
+ *
745
+ * ```text
746
+ * ok agent "market-research" — 1794ms, 224 tok, $0.0008
747
+ * in: Research the market for Acme Coffee Roasters …
748
+ * out: Demand is steady; specialty buyers skew premium …
749
+ * ```
750
+ *
751
+ * Returns `[]` when the span carries no content — capture disabled, or a
752
+ * composite node with no own I/O. Each value is stringified (JSON for
753
+ * non-strings), whitespace-collapsed to stay scannable, and truncated to
754
+ * `maxChars` (default {@link DEFAULT_IO_MAX_CHARS}) with an ellipsis. Use
755
+ * the file exporter for the full, untruncated payload.
756
+ *
757
+ * @example
758
+ * formatSpanIO(toolSpan, 1);
759
+ * // [' in: {"query":"specialty coffee demand"}', ' out: {"results":[…]}']
760
+ */
761
+ function formatSpanIO(span, depth = 0, maxChars = DEFAULT_IO_MAX_CHARS) {
762
+ const lines = [];
763
+ const indent = " ".repeat(depth + 1);
764
+ if (span.input !== void 0) lines.push(`${indent}in: ${preview(span.input, maxChars)}`);
765
+ if (span.output !== void 0) lines.push(`${indent}out: ${preview(span.output, maxChars)}`);
766
+ return lines;
767
+ }
768
+ /**
769
+ * One-line, length-capped preview of a captured value. Strings pass
770
+ * through; everything else is JSON-stringified (falling back to
771
+ * `String()` on a circular / unstringifiable value). Internal whitespace
772
+ * is collapsed so the preview never breaks the tree layout.
773
+ */
774
+ function preview(value, maxChars) {
775
+ const collapsed = (typeof value === "string" ? value : stringify(value)).replace(/\s+/g, " ").trim();
776
+ return collapsed.length > maxChars ? `${collapsed.slice(0, maxChars)}…` : collapsed;
777
+ }
778
+ function stringify(value) {
779
+ try {
780
+ return JSON.stringify(value) ?? String(value);
781
+ } catch {
782
+ return String(value);
783
+ }
784
+ }
785
+
662
786
  //#endregion
663
787
  //#region ../@warlock.js/ai-panoptic/src/exporters/console/format-span-line.ts
664
788
  /**
@@ -705,9 +829,10 @@ const EXPORTER_NAME$3 = "console";
705
829
  * as the reference implementation of the contract.
706
830
  *
707
831
  * By default it prints one summary line per completed trace. Set
708
- * `tree: true` to print the full indented span tree, and
709
- * `streaming: true` to also print each span the moment it finalizes
710
- * (via the optional `exportSpan` hook).
832
+ * `tree: true` to print the full indented span tree, `io: true` to also
833
+ * print each span's captured `input` / `output` (needs the collector's
834
+ * `captureContent`), and `streaming: true` to print each span the moment
835
+ * it finalizes (via the optional `exportSpan` hook).
711
836
  *
712
837
  * @example
713
838
  * collector.use(consoleExporter());
@@ -715,36 +840,52 @@ const EXPORTER_NAME$3 = "console";
715
840
  *
716
841
  * @example
717
842
  * collector.use(consoleExporter({ tree: true }));
843
+ *
844
+ * @example
845
+ * // Full content trace — prompts, responses, and tool I/O:
846
+ * const observe = panoptic({
847
+ * captureContent: true,
848
+ * exporters: [consoleExporter({ tree: true, io: true })],
849
+ * });
718
850
  */
719
851
  function consoleExporter(options = {}) {
720
852
  const sink = options.console ?? console;
721
853
  const tree = options.tree ?? false;
854
+ const io = options.io ?? false;
855
+ const ioMaxChars = options.ioMaxChars;
722
856
  const exporter = {
723
857
  name: EXPORTER_NAME$3,
724
858
  export(trace) {
725
- writeTrace(sink, trace, tree);
859
+ writeTrace(sink, trace, tree, io, ioMaxChars);
726
860
  }
727
861
  };
728
862
  if (options.streaming) exporter.exportSpan = (span) => {
729
863
  sink.log(formatSpanLine(span));
864
+ if (io) for (const line of formatSpanIO(span, 0, ioMaxChars)) sink.log(line);
730
865
  };
731
866
  return exporter;
732
867
  }
733
868
  /**
734
869
  * Write a completed trace — either a single root summary line or the
735
- * full indented tree. Failed traces are routed to `console.error` so
736
- * they surface at the right severity in log aggregators.
870
+ * full indented tree, each span optionally followed by its captured
871
+ * `input` / `output`. Failed / cancelled spans route to `console.error`
872
+ * so they surface at the right severity in log aggregators.
737
873
  */
738
- function writeTrace(sink, trace, tree) {
874
+ function writeTrace(sink, trace, tree, io, ioMaxChars) {
739
875
  if (!tree) {
740
- const line = formatSpanLine(trace.root);
741
- writeAtSeverity(sink, trace.root.status, line);
876
+ writeSpan(sink, trace.root, 0, io, ioMaxChars);
742
877
  return;
743
878
  }
744
- for (const span of walkSpans(trace.root)) {
745
- const line = formatSpanLine(span, spanDepth(trace.root, span.spanId));
746
- writeAtSeverity(sink, span.status, line);
747
- }
879
+ for (const span of walkSpans(trace.root)) writeSpan(sink, span, spanDepth(trace.root, span.spanId), io, ioMaxChars);
880
+ }
881
+ /**
882
+ * Write one span's line and — when `io` is on — its captured content,
883
+ * all routed at the span's own severity so a failed span keeps its
884
+ * content beside it in the error stream.
885
+ */
886
+ function writeSpan(sink, span, depth, io, ioMaxChars) {
887
+ writeAtSeverity(sink, span.status, formatSpanLine(span, depth));
888
+ if (io) for (const line of formatSpanIO(span, depth, ioMaxChars)) writeAtSeverity(sink, span.status, line);
748
889
  }
749
890
  /**
750
891
  * Route a line to `error` when the span failed/cancelled, otherwise to
@@ -901,12 +1042,16 @@ function loadLangfuse() {
901
1042
  * installed, and a missing SDK surfaces as a curated "install this"
902
1043
  * error when the exporter first needs to build a client.
903
1044
  *
904
- * The root {@link TraceSpan} becomes a Langfuse trace; each descendant
905
- * becomes a nested observation a `generation` when it produced tokens
906
- * (LLM-backed agents, supervisors), otherwise a plain `span` (tools,
907
- * callbacks). Timing, status, version, and rolled-up `usage` are mapped
908
- * 1:1; the rest of the GenAI attributes ride along as observation
909
- * metadata.
1045
+ * The root {@link TraceSpan} becomes a Langfuse trace AND its top
1046
+ * observation (so the root execution's own tokens are metered, not just
1047
+ * its children's); every node is an observation — a `generation` when it
1048
+ * metered its OWN tokens (LLM-backed agents, supervisors), otherwise a
1049
+ * plain `span` (tools, callbacks, and composite nodes whose tokens came
1050
+ * only from children). Timing, status, and version map 1:1; each
1051
+ * generation reports its own token usage (rolled-up minus children) so
1052
+ * the trace total isn't double-counted; non-usage GenAI attributes ride
1053
+ * along as metadata, and captured content (under `captureContent`)
1054
+ * surfaces as native `input` / `output`.
910
1055
  *
911
1056
  * @example
912
1057
  * collector.use(langfuseExporter({ publicKey: "pk-...", secretKey: "sk-..." }));
@@ -948,20 +1093,20 @@ function langfuseExporter(options) {
948
1093
  */
949
1094
  function emitTrace(client, trace) {
950
1095
  const root = trace.root;
951
- const langfuseTrace = client.trace({
1096
+ emitObservation(client.trace({
952
1097
  id: root.traceId,
953
1098
  name: root.name,
954
1099
  sessionId: trace.sessionId,
955
1100
  version: root.version,
956
1101
  timestamp: new Date(root.startedAt),
957
- metadata: toGenAiAttributes(root)
958
- });
959
- for (const child of root.children) emitObservation(langfuseTrace, child);
1102
+ metadata: langfuseMetadata(root)
1103
+ }), root);
960
1104
  }
961
1105
  /**
962
1106
  * Map one {@link TraceSpan} onto a Langfuse observation under `parent`,
963
- * then recurse its children. Token-producing spans become
964
- * `generation`s; everything else becomes a plain `span`.
1107
+ * then recurse its children. Spans that metered their OWN tokens become
1108
+ * `generation`s; everything else (tools, callbacks, composite nodes)
1109
+ * becomes a plain `span`.
965
1110
  */
966
1111
  function emitObservation(parent, span) {
967
1112
  const body = {
@@ -972,14 +1117,17 @@ function emitObservation(parent, span) {
972
1117
  level: toLevel(span),
973
1118
  statusMessage: span.error?.message,
974
1119
  version: span.version,
975
- metadata: toGenAiAttributes(span)
1120
+ metadata: langfuseMetadata(span)
976
1121
  };
1122
+ if (span.input !== void 0) body.input = span.input;
1123
+ if (span.output !== void 0) body.output = span.output;
977
1124
  let observation;
978
- if (producedTokens(span)) {
1125
+ const own = ownUsage(span);
1126
+ if (own.total > 0) {
979
1127
  body.usage = {
980
- input: span.usage.input,
981
- output: span.usage.output,
982
- total: span.usage.total,
1128
+ input: own.input,
1129
+ output: own.output,
1130
+ total: own.total,
983
1131
  unit: "TOKENS"
984
1132
  };
985
1133
  observation = parent.generation(body);
@@ -988,20 +1136,60 @@ function emitObservation(parent, span) {
988
1136
  observation.end({ endTime: body.endTime });
989
1137
  }
990
1138
  /**
991
- * A span counts as an LLM `generation` when it metered any tokens. Pure
992
- * tools/callbacks contribute zero own-cost and map to plain spans.
1139
+ * Own token usage for a span its rolled-up {@link TraceSpan.usage}
1140
+ * minus the rolled-up usage of its direct children. `TraceSpan.usage` is
1141
+ * the subtree total (this node plus every descendant), so subtracting the
1142
+ * children leaves the tokens THIS node alone metered, clamped at zero
1143
+ * defensively.
993
1144
  *
994
- * **Known limitation.** `TraceSpan.usage` is the ROLLED-UP usage (this
995
- * node's own cost plus the sum of its children), not own-usage — the
996
- * core `BaseReport` exposes no separate own-usage field, so a composite
997
- * node (e.g. an agent that itself made no model call but whose tool
998
- * children did) is classified as a `generation` on its children's
999
- * tokens. We cannot classify on own-usage until the report shape carries
1000
- * it; the rolled-up `usage` block emitted on such a generation therefore
1001
- * double-counts tokens already attributed to descendant observations.
1145
+ * Langfuse sums observation usage into the trace total, so reporting
1146
+ * own-usage on each generation (rather than the subtree rollup) is what
1147
+ * keeps the trace total correct instead of multiply-counting nested
1148
+ * spans. A composite node with no own tokens (e.g. a workflow whose
1149
+ * tokens all came from agent children) yields `total: 0` and is emitted
1150
+ * as a plain span, not a generation.
1002
1151
  */
1003
- function producedTokens(span) {
1004
- return span.usage.total > 0;
1152
+ function ownUsage(span) {
1153
+ let childInput = 0;
1154
+ let childOutput = 0;
1155
+ let childTotal = 0;
1156
+ for (const child of span.children) {
1157
+ childInput += child.usage.input;
1158
+ childOutput += child.usage.output;
1159
+ childTotal += child.usage.total;
1160
+ }
1161
+ return {
1162
+ input: Math.max(0, span.usage.input - childInput),
1163
+ output: Math.max(0, span.usage.output - childOutput),
1164
+ total: Math.max(0, span.usage.total - childTotal)
1165
+ };
1166
+ }
1167
+ /**
1168
+ * Token-usage + cost keys that the per-observation `usage` block already
1169
+ * carries authoritatively (as OWN usage). Omitting them from `metadata`
1170
+ * avoids a confusing contradiction — metadata would otherwise show the
1171
+ * rolled-up subtree totals next to an own-usage `usage` block. Cost is
1172
+ * omitted for the same reason; Langfuse prices the own tokens itself.
1173
+ */
1174
+ const LANGFUSE_METADATA_OMIT = new Set([
1175
+ GEN_AI_ATTRIBUTES.usageInputTokens,
1176
+ GEN_AI_ATTRIBUTES.usageOutputTokens,
1177
+ WARLOCK_ATTRIBUTES.totalTokens,
1178
+ WARLOCK_ATTRIBUTES.cachedTokens,
1179
+ WARLOCK_ATTRIBUTES.reasoningTokens,
1180
+ WARLOCK_ATTRIBUTES.costUsd
1181
+ ]);
1182
+ /**
1183
+ * Observation metadata — the GenAI attribute set minus the usage/cost
1184
+ * keys that live authoritatively on the `usage` block (see
1185
+ * {@link LANGFUSE_METADATA_OMIT}). Keeps model identity, report type,
1186
+ * version, session id, and any collector-set attributes.
1187
+ */
1188
+ function langfuseMetadata(span) {
1189
+ const all = toGenAiAttributes(span);
1190
+ const metadata = {};
1191
+ for (const [key, value] of Object.entries(all)) if (!LANGFUSE_METADATA_OMIT.has(key)) metadata[key] = value;
1192
+ return metadata;
1005
1193
  }
1006
1194
  /**
1007
1195
  * Map the Panoptic span status onto a Langfuse observation level —
@@ -1113,9 +1301,24 @@ function emitSpan(tracer, span, parentContext, options) {
1113
1301
  function applyAttributes(otelSpan, span, options) {
1114
1302
  const attributes = toGenAiAttributes(span);
1115
1303
  if (options.system !== void 0 && attributes[GEN_AI_ATTRIBUTES.system] === void 0) attributes[GEN_AI_ATTRIBUTES.system] = options.system;
1304
+ if (span.input !== void 0) attributes[GEN_AI_ATTRIBUTES.prompt] = stringifyContent(span.input);
1305
+ if (span.output !== void 0) attributes[GEN_AI_ATTRIBUTES.completion] = stringifyContent(span.output);
1116
1306
  otelSpan.setAttributes(attributes);
1117
1307
  }
1118
1308
  /**
1309
+ * Coerce a captured content value to a string OTel attribute. Strings
1310
+ * pass through; structured values are JSON-encoded (falling back to
1311
+ * `String()` if they can't be serialized).
1312
+ */
1313
+ function stringifyContent(value) {
1314
+ if (typeof value === "string") return value;
1315
+ try {
1316
+ return JSON.stringify(value) ?? String(value);
1317
+ } catch {
1318
+ return String(value);
1319
+ }
1320
+ }
1321
+ /**
1119
1322
  * Map the Panoptic span status onto the OTel span status, recording the
1120
1323
  * normalized error as an exception event + ERROR status when present.
1121
1324
  */
@@ -1242,7 +1445,10 @@ const DEFAULT_COMPLETED_EVENTS = [
1242
1445
  */
1243
1446
  var PanopticSubscriber = class {
1244
1447
  constructor(options = {}) {
1245
- this.collector = options.collector ?? createCollector();
1448
+ this.collector = options.collector ?? createCollector({
1449
+ captureContent: options.captureContent,
1450
+ redactContent: options.redactContent
1451
+ });
1246
1452
  for (const exporter of options.exporters ?? []) this.collector.use(exporter);
1247
1453
  this.completedEvents = options.completedEvents ?? [...DEFAULT_COMPLETED_EVENTS];
1248
1454
  this.middlewareName = options.middlewareName ?? "panoptic";
@@ -1355,6 +1561,7 @@ exports.createPanopticMiddleware = createPanopticMiddleware;
1355
1561
  exports.emptyUsage = emptyUsage;
1356
1562
  exports.extractSpanAttributes = extractSpanAttributes;
1357
1563
  exports.fileExporter = fileExporter;
1564
+ exports.formatSpanIO = formatSpanIO;
1358
1565
  exports.formatSpanLine = formatSpanLine;
1359
1566
  exports.langfuseExporter = langfuseExporter;
1360
1567
  exports.matchTrace = matchTrace;