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