@posthog/ai 8.7.0 → 8.8.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.
@@ -10,6 +10,33 @@ const isObject = value => {
10
10
  return value !== null && typeof value === 'object' && !Array.isArray(value);
11
11
  };
12
12
 
13
+ /** @internal */
14
+
15
+ /** @internal */
16
+
17
+ /** @internal */
18
+ function isFullAiCaptureEnabled(client) {
19
+ return client?.enableFullAiCapture === true;
20
+ }
21
+
22
+ /** @internal */
23
+ function captureAiEvent(client, event) {
24
+ if (isFullAiCaptureEnabled(client) && typeof client.captureAi === 'function') {
25
+ client.captureAi(event);
26
+ return;
27
+ }
28
+ client.capture(event);
29
+ }
30
+
31
+ /** @internal */
32
+ async function captureAiEventImmediate(client, event) {
33
+ if (isFullAiCaptureEnabled(client) && typeof client.captureAiImmediate === 'function') {
34
+ await client.captureAiImmediate(event);
35
+ return;
36
+ }
37
+ await client.captureImmediate(event);
38
+ }
39
+
13
40
  const DATA_URL_PREFIX_RE = /^data:([^;,\s]+)(?:;[^;,\s]+)*;base64,/i;
14
41
  const BASE64_ALPHABET_RE = /^[A-Za-z0-9+/_=-]+$/;
15
42
  class Base64Recognizer {
@@ -115,7 +142,6 @@ class BinaryContentRedactor {
115
142
  this.recognizer = recognizer;
116
143
  }
117
144
  redact(value, mediaType) {
118
- if (this.isMultimodalEnabled()) return value;
119
145
  this.visited = new WeakSet();
120
146
  return this.walk(value, mediaType ? new MediaTypeContext(undefined, undefined, mediaType) : MediaTypeContext.EMPTY);
121
147
  }
@@ -159,24 +185,26 @@ class BinaryContentRedactor {
159
185
  if (mediaType === 'application/octet-stream') return '[base64 file redacted]';
160
186
  return `[base64 ${mediaType} redacted]`;
161
187
  }
162
- isMultimodalEnabled() {
163
- const val = process.env._INTERNAL_LLMA_MULTIMODAL || '';
164
- return val.toLowerCase() === 'true' || val === '1' || val.toLowerCase() === 'yes';
165
- }
166
188
  }
167
189
 
168
190
  const redactor = new BinaryContentRedactor();
169
191
  function redactBase64DataUrl(str, mediaType) {
170
192
  return redactor.redact(str, mediaType);
171
193
  }
172
- const sanitizeVercel = data => redactor.redact(data);
194
+ const sanitize = (data, client) => isFullAiCaptureEnabled(client) ? data : redactor.redact(data);
195
+ const sanitizeVercel = (data, client) => sanitize(data, client);
173
196
 
174
197
  const TOKEN_PROPERTY_KEYS = new Set(['$ai_input_tokens', '$ai_output_tokens', '$ai_cache_read_input_tokens', '$ai_cache_creation_input_tokens', '$ai_total_tokens', '$ai_reasoning_tokens']);
198
+
199
+ /**
200
+ * Whether the caller supplied their own token counts, which override the ones the SDK
201
+ * derived from the provider response.
202
+ */
203
+ function hasTokenOverrides(posthogProperties) {
204
+ return !!posthogProperties && Object.keys(posthogProperties).some(key => TOKEN_PROPERTY_KEYS.has(key));
205
+ }
175
206
  function getTokensSource(posthogProperties) {
176
- if (posthogProperties && Object.keys(posthogProperties).some(key => TOKEN_PROPERTY_KEYS.has(key))) {
177
- return 'passthrough';
178
- }
179
- return 'sdk';
207
+ return hasTokenOverrides(posthogProperties) ? 'passthrough' : 'sdk';
180
208
  }
181
209
 
182
210
  // limit large outputs by truncating to 200kb (approx 200k bytes)
@@ -246,11 +274,14 @@ function toSafeString(input) {
246
274
  return '';
247
275
  }
248
276
  }
249
- const truncate = input => {
277
+ const truncate = (input, client) => {
250
278
  const str = toSafeString(input);
251
279
  if (str === '') {
252
280
  return '';
253
281
  }
282
+ if (isFullAiCaptureEnabled(client)) {
283
+ return str;
284
+ }
254
285
 
255
286
  // Check if we need to truncate and ensure STRING_FORMAT is respected
256
287
  const buffer = sharedTextEncoder.encode(str);
@@ -406,7 +437,7 @@ function sanitizeValues(obj) {
406
437
  return jsonSafe;
407
438
  }
408
439
 
409
- var version = "8.7.0";
440
+ var version = "8.8.0";
410
441
 
411
442
  const DEFAULT_MAX_DEPTH = 3;
412
443
  const MAX_STACK_LINES = 20;
@@ -573,6 +604,10 @@ const captureAiGeneration = async (client, options) => {
573
604
  $ai_total_cost_usd: inputCostUSD + outputCostUSD
574
605
  };
575
606
  }
607
+
608
+ // The caller's own token counts override the SDK-derived ones further down, via the
609
+ // `options.properties` spread.
610
+ const tokensOverridden = hasTokenOverrides(options.properties);
576
611
  const additionalTokenValues = {
577
612
  ...(usage.reasoningTokens ? {
578
613
  $ai_reasoning_tokens: usage.reasoningTokens
@@ -583,6 +618,18 @@ const captureAiGeneration = async (client, options) => {
583
618
  ...(usage.cacheCreationInputTokens ? {
584
619
  $ai_cache_creation_input_tokens: usage.cacheCreationInputTokens
585
620
  } : {}),
621
+ // Checked against undefined rather than truthiness, because false is the meaningful
622
+ // value here and a truthiness guard would drop it.
623
+ //
624
+ // Dropped entirely when the caller overrides the token counts: the flag describes how
625
+ // the SDK-derived counts relate to each other, so against passthrough counts it can be
626
+ // wrong in the expensive direction. Declaring inclusive over counts that are actually
627
+ // exclusive makes ingestion subtract the cache pool that was never in the input. A
628
+ // caller who knows their own accounting model can still pass
629
+ // `$ai_cache_reporting_exclusive` themselves, and that value wins.
630
+ ...(usage.cacheReportingExclusive !== undefined && !tokensOverridden ? {
631
+ $ai_cache_reporting_exclusive: usage.cacheReportingExclusive
632
+ } : {}),
586
633
  ...(usage.webSearchCount ? {
587
634
  $ai_web_search_count: usage.webSearchCount
588
635
  } : {}),
@@ -639,9 +686,9 @@ const captureAiGeneration = async (client, options) => {
639
686
  groups: options.groups
640
687
  };
641
688
  if (options.captureImmediate) {
642
- await client.captureImmediate(event);
689
+ await captureAiEventImmediate(client, event);
643
690
  } else {
644
- client.capture(event);
691
+ captureAiEvent(client, event);
645
692
  }
646
693
  } catch (error) {
647
694
  // Telemetry failures must never affect the instrumented provider call.
@@ -664,12 +711,12 @@ function getSpecificationVersion(model) {
664
711
 
665
712
  // Content types for the output array
666
713
 
667
- const redactFileData = (data, mediaType) => {
714
+ const redactFileData = (data, mediaType, client) => {
668
715
  if (data instanceof URL) {
669
- return redactBase64DataUrl(data.toString(), data.protocol === 'data:' ? mediaType : undefined);
716
+ return isFullAiCaptureEnabled(client) ? data.toString() : redactBase64DataUrl(data.toString(), data.protocol === 'data:' ? mediaType : undefined);
670
717
  }
671
718
  if (isString(data)) {
672
- return redactBase64DataUrl(data, mediaType);
719
+ return isFullAiCaptureEnabled(client) ? data : redactBase64DataUrl(data, mediaType);
673
720
  }
674
721
  return undefined;
675
722
  };
@@ -684,7 +731,7 @@ const mapVercelParams = params => {
684
731
  stream: params.stream
685
732
  };
686
733
  };
687
- const mapVercelPrompt = messages => {
734
+ const mapVercelPrompt = (messages, client) => {
688
735
  // Map and truncate individual content
689
736
  const inputs = messages.map(message => {
690
737
  let content;
@@ -693,7 +740,7 @@ const mapVercelPrompt = messages => {
693
740
  if (message.role === 'system') {
694
741
  content = [{
695
742
  type: 'text',
696
- text: truncate(toContentString(message.content))
743
+ text: truncate(toContentString(message.content), client)
697
744
  }];
698
745
  } else {
699
746
  // Handle other roles which have array content
@@ -702,11 +749,11 @@ const mapVercelPrompt = messages => {
702
749
  if (c.type === 'text') {
703
750
  return {
704
751
  type: 'text',
705
- text: truncate(c.text)
752
+ text: truncate(c.text, client)
706
753
  };
707
754
  } else if (c.type === 'file') {
708
755
  // Redact base64 data URLs and raw base64 to prevent oversized events
709
- const fileData = redactFileData(c.data, c.mediaType) ?? 'raw files not supported';
756
+ const fileData = redactFileData(c.data, c.mediaType, client) ?? 'raw files not supported';
710
757
  return {
711
758
  type: 'file',
712
759
  file: fileData,
@@ -715,7 +762,7 @@ const mapVercelPrompt = messages => {
715
762
  } else if (c.type === 'reasoning') {
716
763
  return {
717
764
  type: 'reasoning',
718
- text: truncate(c.text)
765
+ text: truncate(c.text, client)
719
766
  };
720
767
  } else if (c.type === 'tool-call') {
721
768
  return {
@@ -729,7 +776,7 @@ const mapVercelPrompt = messages => {
729
776
  type: 'tool-result',
730
777
  toolCallId: c.toolCallId,
731
778
  toolName: c.toolName,
732
- output: sanitizeVercel(c.output),
779
+ output: sanitizeVercel(c.output, client),
733
780
  isError: c.isError
734
781
  };
735
782
  }
@@ -742,7 +789,7 @@ const mapVercelPrompt = messages => {
742
789
  // Fallback for non-array content
743
790
  content = [{
744
791
  type: 'text',
745
- text: truncate(toContentString(message.content))
792
+ text: truncate(toContentString(message.content), client)
746
793
  }];
747
794
  }
748
795
  }
@@ -751,6 +798,12 @@ const mapVercelPrompt = messages => {
751
798
  content
752
799
  };
753
800
  });
801
+
802
+ // Full AI capture means no truncation of any kind; the aggregate trim below exists
803
+ // only to keep the default-mode payload under MAX_OUTPUT_SIZE.
804
+ if (isFullAiCaptureEnabled(client)) {
805
+ return inputs;
806
+ }
754
807
  try {
755
808
  // Trim the inputs array until its serialized JSON size fits within MAX_OUTPUT_SIZE.
756
809
  // Pre-compute each message's byte size once so we can shift by accumulated budget
@@ -787,12 +840,12 @@ const mapVercelPrompt = messages => {
787
840
  }
788
841
  return inputs;
789
842
  };
790
- const mapVercelOutput = result => {
843
+ const mapVercelOutput = (result, client) => {
791
844
  const content = result.map(item => {
792
845
  if (item.type === 'text') {
793
846
  return {
794
847
  type: 'text',
795
- text: truncate(item.text)
848
+ text: truncate(item.text, client)
796
849
  };
797
850
  }
798
851
  if (item.type === 'tool-call') {
@@ -810,15 +863,15 @@ const mapVercelOutput = result => {
810
863
  if (item.type === 'reasoning') {
811
864
  return {
812
865
  type: 'reasoning',
813
- text: truncate(item.text)
866
+ text: truncate(item.text, client)
814
867
  };
815
868
  }
816
869
  if (item.type === 'file') {
817
870
  // Handle files similar to input mapping - avoid large base64 data
818
- let fileData = redactFileData(item.data, item.mediaType) ?? `[binary ${item.mediaType} file]`;
871
+ let fileData = redactFileData(item.data, item.mediaType, client) ?? `[binary ${item.mediaType} file]`;
819
872
 
820
- // If not redacted and still large, replace with size indicator
821
- if (typeof item.data === 'string' && fileData === item.data && item.data.length > 1000) {
873
+ // Skipped under full AI capture: media stays untouched, so no placeholder swap either.
874
+ if (!isFullAiCaptureEnabled(client) && typeof item.data === 'string' && fileData === item.data && item.data.length > 1000) {
822
875
  fileData = `[${item.mediaType} file - ${item.data.length} bytes]`;
823
876
  }
824
877
  return {
@@ -840,7 +893,7 @@ const mapVercelOutput = result => {
840
893
  // Fallback for unknown types - try to extract text if possible
841
894
  return {
842
895
  type: 'text',
843
- text: truncate(JSON.stringify(item))
896
+ text: truncate(JSON.stringify(item), client)
844
897
  };
845
898
  });
846
899
  if (content.length > 0) {
@@ -853,7 +906,7 @@ const mapVercelOutput = result => {
853
906
  try {
854
907
  const jsonOutput = JSON.stringify(result);
855
908
  return [{
856
- content: truncate(jsonOutput),
909
+ content: truncate(jsonOutput, client),
857
910
  role: 'assistant'
858
911
  }];
859
912
  } catch {
@@ -1049,7 +1102,7 @@ const wrapVercelLanguageModel = (model, phClient, options) => {
1049
1102
  const modelId = mergedOptions.posthogModelOverride ?? (result.response?.modelId ? result.response.modelId : model.modelId);
1050
1103
  const provider = mergedOptions.posthogProviderOverride ?? extractProvider(model);
1051
1104
  // result.content is undefined when the model returns only tool calls with no text output
1052
- const content = mapVercelOutput(result.content ?? []);
1105
+ const content = mapVercelOutput(result.content ?? [], phClient);
1053
1106
  const latency = (Date.now() - startTime) / 1000;
1054
1107
  const providerMetadata = result.providerMetadata;
1055
1108
  const additionalTokenValues = extractAdditionalTokenValues(providerMetadata, result.usage);
@@ -1092,7 +1145,7 @@ const wrapVercelLanguageModel = (model, phClient, options) => {
1092
1145
  ...baseOptions,
1093
1146
  model: modelId,
1094
1147
  provider: provider,
1095
- input: mergedOptions.posthogPrivacyMode ? '' : mapVercelPrompt(params.prompt),
1148
+ input: mergedOptions.posthogPrivacyMode ? '' : mapVercelPrompt(params.prompt, phClient),
1096
1149
  output: content,
1097
1150
  latency,
1098
1151
  baseURL,
@@ -1109,7 +1162,7 @@ const wrapVercelLanguageModel = (model, phClient, options) => {
1109
1162
  ...baseOptions,
1110
1163
  model: modelId,
1111
1164
  provider: model.provider,
1112
- input: mergedOptions.posthogPrivacyMode ? '' : mapVercelPrompt(params.prompt),
1165
+ input: mergedOptions.posthogPrivacyMode ? '' : mapVercelPrompt(params.prompt, phClient),
1113
1166
  output: [],
1114
1167
  latency: 0,
1115
1168
  baseURL,
@@ -1243,13 +1296,13 @@ const wrapVercelLanguageModel = (model, phClient, options) => {
1243
1296
  if (reasoningText) {
1244
1297
  content.push({
1245
1298
  type: 'reasoning',
1246
- text: truncate(reasoningText)
1299
+ text: truncate(reasoningText, phClient)
1247
1300
  });
1248
1301
  }
1249
1302
  if (generatedText) {
1250
1303
  content.push({
1251
1304
  type: 'text',
1252
- text: truncate(generatedText)
1305
+ text: truncate(generatedText, phClient)
1253
1306
  });
1254
1307
  }
1255
1308
  for (const toolCall of toolCallsInProgress.values()) {
@@ -1284,7 +1337,7 @@ const wrapVercelLanguageModel = (model, phClient, options) => {
1284
1337
  ...baseOptions,
1285
1338
  model: modelId,
1286
1339
  provider: provider,
1287
- input: mergedOptions.posthogPrivacyMode ? '' : mapVercelPrompt(params.prompt),
1340
+ input: mergedOptions.posthogPrivacyMode ? '' : mapVercelPrompt(params.prompt, phClient),
1288
1341
  output,
1289
1342
  latency,
1290
1343
  timeToFirstToken,
@@ -1340,7 +1393,7 @@ const wrapVercelLanguageModel = (model, phClient, options) => {
1340
1393
  ...baseOptions,
1341
1394
  model: modelId,
1342
1395
  provider: provider,
1343
- input: mergedOptions.posthogPrivacyMode ? '' : mapVercelPrompt(params.prompt),
1396
+ input: mergedOptions.posthogPrivacyMode ? '' : mapVercelPrompt(params.prompt, phClient),
1344
1397
  output: [],
1345
1398
  latency: 0,
1346
1399
  baseURL,