@posthog/core 1.27.9 → 1.28.1

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 (55) hide show
  1. package/dist/index.d.ts +2 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/logs/index.d.ts +94 -4
  4. package/dist/logs/index.d.ts.map +1 -1
  5. package/dist/logs/index.js +149 -4
  6. package/dist/logs/index.mjs +150 -5
  7. package/dist/logs/logs-utils.d.ts +2 -1
  8. package/dist/logs/logs-utils.d.ts.map +1 -1
  9. package/dist/logs/types.d.ts +140 -8
  10. package/dist/logs/types.d.ts.map +1 -1
  11. package/dist/posthog-core-stateless.d.ts +34 -0
  12. package/dist/posthog-core-stateless.d.ts.map +1 -1
  13. package/dist/posthog-core-stateless.js +46 -0
  14. package/dist/posthog-core-stateless.mjs +46 -0
  15. package/dist/posthog-core.d.ts.map +1 -1
  16. package/dist/posthog-core.js +2 -0
  17. package/dist/posthog-core.mjs +2 -0
  18. package/dist/surveys/events.d.ts +22 -0
  19. package/dist/surveys/events.d.ts.map +1 -0
  20. package/dist/surveys/events.js +95 -0
  21. package/dist/surveys/events.mjs +43 -0
  22. package/dist/surveys/index.d.ts +4 -0
  23. package/dist/surveys/index.d.ts.map +1 -0
  24. package/dist/surveys/index.js +83 -0
  25. package/dist/surveys/index.mjs +4 -0
  26. package/dist/surveys/translations.d.ts +38 -0
  27. package/dist/surveys/translations.d.ts.map +1 -0
  28. package/dist/surveys/translations.js +207 -0
  29. package/dist/surveys/translations.mjs +158 -0
  30. package/dist/testing/test-utils.d.ts.map +1 -1
  31. package/dist/testing/test-utils.js +1 -0
  32. package/dist/testing/test-utils.mjs +1 -0
  33. package/dist/types.d.ts +31 -2
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/utils/logger.d.ts +1 -1
  36. package/dist/utils/logger.d.ts.map +1 -1
  37. package/dist/utils/logger.js +3 -0
  38. package/dist/utils/logger.mjs +3 -0
  39. package/package.json +26 -2
  40. package/src/index.ts +12 -1
  41. package/src/logs/index.spec.ts +891 -17
  42. package/src/logs/index.ts +341 -11
  43. package/src/logs/logs-utils.spec.ts +2 -1
  44. package/src/logs/logs-utils.ts +1 -1
  45. package/src/logs/types.ts +150 -25
  46. package/src/posthog-core-stateless.ts +80 -0
  47. package/src/posthog-core.ts +6 -0
  48. package/src/surveys/events.spec.ts +52 -0
  49. package/src/surveys/events.ts +80 -0
  50. package/src/surveys/index.ts +18 -0
  51. package/src/surveys/translations.spec.ts +205 -0
  52. package/src/surveys/translations.ts +244 -0
  53. package/src/testing/test-utils.ts +1 -0
  54. package/src/types.ts +38 -2
  55. package/src/utils/logger.ts +6 -2
@@ -1,3 +1,4 @@
1
+ import type { OtlpLogsPayload } from '@posthog/types'
1
2
  import { SimpleEventEmitter } from './eventemitter'
2
3
  import { getFeatureFlagValue, normalizeFlagsResponse } from './featureFlagUtils'
3
4
  import { gzipCompress, isGzipSupported } from './gzip'
@@ -94,6 +95,23 @@ function isPostHogFetchContentTooLargeError(err: unknown): err is PostHogFetchHt
94
95
  return typeof err === 'object' && err instanceof PostHogFetchHttpError && err.status === 413
95
96
  }
96
97
 
98
+ /**
99
+ * Outcome of a logs batch send. Keeps HTTP error classification inside core
100
+ * (single source of truth — same policy events already use in `_flush()`) so
101
+ * PostHogLogs doesn't need to know about specific error types.
102
+ *
103
+ * - ok → records are accepted; drop them from the queue
104
+ * - too-large → 413; caller should halve batch size and retry same records
105
+ * - retry-later → network error; caller keeps records and retries next cycle
106
+ * - fatal → anything else (auth, malformed, etc.); caller drops the
107
+ * batch and surfaces the error
108
+ */
109
+ export type SendLogsBatchOutcome =
110
+ | { kind: 'ok' }
111
+ | { kind: 'too-large' }
112
+ | { kind: 'retry-later'; error: unknown }
113
+ | { kind: 'fatal'; error: unknown }
114
+
97
115
  export enum QuotaLimitedFeature {
98
116
  FeatureFlags = 'feature_flags',
99
117
  Recordings = 'recordings',
@@ -789,6 +807,10 @@ export abstract class PostHogCoreStateless {
789
807
  public async getSurveysStateless(): Promise<SurveyResponse['surveys']> {
790
808
  await this._initPromise
791
809
 
810
+ if (this.disabled) {
811
+ return []
812
+ }
813
+
792
814
  if (this.disableSurveys === true) {
793
815
  this._logger.info('Loading surveys is disabled.')
794
816
  return []
@@ -1053,6 +1075,10 @@ export abstract class PostHogCoreStateless {
1053
1075
  * @throws Error
1054
1076
  */
1055
1077
  async flush(): Promise<void> {
1078
+ if (this.disabled) {
1079
+ return
1080
+ }
1081
+
1056
1082
  // Wait for the current flush operation to finish (regardless of success or failure), then try to flush again.
1057
1083
  // Use allSettled instead of finally to be defensive around flush throwing errors immediately rather than rejecting.
1058
1084
  // Use a custom allSettled implementation to avoid issues with patching Promise on RN
@@ -1179,6 +1205,56 @@ export abstract class PostHogCoreStateless {
1179
1205
  this._events.emit('flush', sentMessages)
1180
1206
  }
1181
1207
 
1208
+ /**
1209
+ * Sends a pre-built OTLP logs payload to `/i/v1/logs`. Returns a tagged
1210
+ * outcome instead of throwing so PostHogLogs doesn't have to know about the
1211
+ * core's error class hierarchy. Error classification lives here (single
1212
+ * source of truth, same policy the events `_flush()` uses for its own
1213
+ * 413 / network / fatal handling).
1214
+ *
1215
+ * 413 is passed through as `too-large` (not auto-retried) so the caller can
1216
+ * shrink `maxBatchRecordsPerPost` and retry the same records.
1217
+ */
1218
+ async _sendLogsBatch(payload: OtlpLogsPayload): Promise<SendLogsBatchOutcome> {
1219
+ if (this.disabled) {
1220
+ return { kind: 'fatal', error: new Error('The client is disabled') }
1221
+ }
1222
+
1223
+ const serialized = JSON.stringify(payload)
1224
+ const url = `${this.host}/i/v1/logs?token=${encodeURIComponent(this.apiKey)}`
1225
+
1226
+ const gzippedPayload = !this.disableCompression ? await gzipCompress(serialized, this.isDebug) : null
1227
+ const fetchOptions: PostHogFetchOptions = {
1228
+ method: 'POST',
1229
+ headers: {
1230
+ ...this.getCustomHeaders(),
1231
+ 'Content-Type': 'application/json',
1232
+ ...(gzippedPayload !== null && { 'Content-Encoding': 'gzip' }),
1233
+ },
1234
+ body: gzippedPayload || serialized,
1235
+ }
1236
+
1237
+ try {
1238
+ await this.fetchWithRetry(url, fetchOptions, {
1239
+ retryCheck: (err) => {
1240
+ if (isPostHogFetchContentTooLargeError(err)) {
1241
+ return false
1242
+ }
1243
+ return isPostHogFetchError(err)
1244
+ },
1245
+ })
1246
+ return { kind: 'ok' }
1247
+ } catch (err) {
1248
+ if (isPostHogFetchContentTooLargeError(err)) {
1249
+ return { kind: 'too-large' }
1250
+ }
1251
+ if (err instanceof PostHogFetchNetworkError) {
1252
+ return { kind: 'retry-later', error: err }
1253
+ }
1254
+ return { kind: 'fatal', error: err }
1255
+ }
1256
+ }
1257
+
1182
1258
  private async fetchWithRetry(
1183
1259
  url: string,
1184
1260
  options: PostHogFetchOptions,
@@ -1241,6 +1317,10 @@ export abstract class PostHogCoreStateless {
1241
1317
  let hasTimedOut = false
1242
1318
  this.clearFlushTimer()
1243
1319
 
1320
+ if (this.disabled) {
1321
+ return
1322
+ }
1323
+
1244
1324
  const doShutdown = async (): Promise<void> => {
1245
1325
  try {
1246
1326
  await this.promiseQueue.join()
@@ -566,6 +566,9 @@ export abstract class PostHogCore extends PostHogCoreStateless {
566
566
 
567
567
  private async remoteConfigAsync(): Promise<PostHogRemoteConfig | undefined> {
568
568
  await this._initPromise
569
+ if (this.disabled) {
570
+ return undefined
571
+ }
569
572
  if (this._remoteConfigResponsePromise) {
570
573
  return this._remoteConfigResponsePromise
571
574
  }
@@ -578,6 +581,9 @@ export abstract class PostHogCore extends PostHogCoreStateless {
578
581
  protected async flagsAsync(options?: FlagsAsyncOptions): Promise<PostHogFeatureFlagsResponse | undefined> {
579
582
  const { sendAnonDistinctId = true, fetchConfig = false, triggerOnRemoteConfig = false } = options ?? {}
580
583
  await this._initPromise
584
+ if (this.disabled) {
585
+ return undefined
586
+ }
581
587
  if (this._flagsResponsePromise) {
582
588
  // Queue the reload request instead of dropping it
583
589
  // This ensures that requests with $anon_distinct_id (from identify()) are not lost
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import {
3
+ buildSurveyResponseProperties,
4
+ getSurveyInteractionProperty,
5
+ getSurveyResponseKey,
6
+ getSurveyResponseValue,
7
+ surveyHasResponses,
8
+ } from './events'
9
+
10
+ describe('survey event helpers', () => {
11
+ const survey = {
12
+ id: 'survey-1',
13
+ current_iteration: 2,
14
+ questions: [
15
+ { id: 'q1', question: 'Rate us', originalQuestionIndex: 0 },
16
+ { id: 'q2', question: 'Anything else?', originalQuestionIndex: 1 },
17
+ ],
18
+ }
19
+
20
+ it('builds response properties with current and legacy response keys', () => {
21
+ const responses = {
22
+ [getSurveyResponseKey('q1')]: 5,
23
+ [getSurveyResponseKey('q2')]: ['fast', 'clear'],
24
+ }
25
+
26
+ expect(buildSurveyResponseProperties(responses, survey)).toEqual({
27
+ $survey_questions: [
28
+ { id: 'q1', question: 'Rate us', response: 5 },
29
+ { id: 'q2', question: 'Anything else?', response: ['fast', 'clear'] },
30
+ ],
31
+ $survey_response_q1: 5,
32
+ $survey_response_q2: ['fast', 'clear'],
33
+ $survey_response: 5,
34
+ $survey_response_1: ['fast', 'clear'],
35
+ })
36
+ })
37
+
38
+ it('copies array response values before returning them', () => {
39
+ const responses = { [getSurveyResponseKey('q1')]: ['a'] }
40
+
41
+ const response = getSurveyResponseValue(responses, 'q1')
42
+
43
+ expect(response).toEqual(['a'])
44
+ expect(response).not.toBe(responses.$survey_response_q1)
45
+ })
46
+
47
+ it('detects non-nullish responses and builds interaction property names', () => {
48
+ expect(surveyHasResponses({ $survey_response_q1: null })).toBe(false)
49
+ expect(surveyHasResponses({ $survey_response_q1: 0 })).toBe(true)
50
+ expect(getSurveyInteractionProperty(survey, 'responded')).toBe('$survey_responded/survey-1/2')
51
+ })
52
+ })
@@ -0,0 +1,80 @@
1
+ import { SurveyResponses, SurveyResponseValue } from '../types'
2
+ import { isArray, isNullish, isUndefined } from '../utils'
3
+
4
+ export const SURVEY_LANGUAGE_PROPERTY = '$survey_language'
5
+
6
+ export function getSurveyResponseKey(questionId: string): string {
7
+ return `$survey_response_${questionId}`
8
+ }
9
+
10
+ export function getSurveyOldResponseKey(originalQuestionIndex: number): string {
11
+ return originalQuestionIndex === 0 ? '$survey_response' : `$survey_response_${originalQuestionIndex}`
12
+ }
13
+
14
+ export function getSurveyResponseValue(
15
+ responses: SurveyResponses,
16
+ questionId?: string
17
+ ): SurveyResponseValue | undefined {
18
+ if (!questionId) {
19
+ return null
20
+ }
21
+ const response = responses[getSurveyResponseKey(questionId)]
22
+ if (isArray(response)) {
23
+ return [...response]
24
+ }
25
+ return response
26
+ }
27
+
28
+ export function buildSurveyResponseProperties(
29
+ responses: SurveyResponses = {},
30
+ survey: SurveyForResponses
31
+ ): Record<string, unknown> {
32
+ const oldFormatResponses: SurveyResponses = {}
33
+ survey.questions.forEach((question: SurveyQuestionForResponses) => {
34
+ if (isUndefined(question.originalQuestionIndex)) {
35
+ return
36
+ }
37
+ const oldResponseKey = getSurveyOldResponseKey(question.originalQuestionIndex)
38
+ const response = getSurveyResponseValue(responses, question.id)
39
+ if (!isUndefined(response)) {
40
+ oldFormatResponses[oldResponseKey] = response
41
+ }
42
+ })
43
+
44
+ return {
45
+ $survey_questions: survey.questions.map((question: SurveyQuestionForResponses) => ({
46
+ id: question.id,
47
+ question: question.question,
48
+ response: getSurveyResponseValue(responses, question.id),
49
+ })),
50
+ ...responses,
51
+ ...oldFormatResponses,
52
+ }
53
+ }
54
+
55
+ export function surveyHasResponses(responses: SurveyResponses = {}): boolean {
56
+ return Object.values(responses).some((response) => !isNullish(response))
57
+ }
58
+
59
+ export function getSurveyInteractionProperty(survey: SurveyWithIteration, action: string): string {
60
+ let surveyProperty = `$survey_${action}/${survey.id}`
61
+ if (survey.current_iteration && survey.current_iteration > 0) {
62
+ surveyProperty = `$survey_${action}/${survey.id}/${survey.current_iteration}`
63
+ }
64
+
65
+ return surveyProperty
66
+ }
67
+ type SurveyQuestionForResponses = {
68
+ id?: string
69
+ question: string
70
+ originalQuestionIndex?: number
71
+ }
72
+
73
+ type SurveyForResponses = {
74
+ questions: SurveyQuestionForResponses[]
75
+ }
76
+
77
+ type SurveyWithIteration = {
78
+ id: string
79
+ current_iteration?: number | null
80
+ }
@@ -0,0 +1,18 @@
1
+ export { getValidationError, getLengthFromRules, getRequirementsHint } from './validation'
2
+ export {
3
+ buildSurveyResponseProperties,
4
+ getSurveyInteractionProperty,
5
+ getSurveyOldResponseKey,
6
+ getSurveyResponseKey,
7
+ getSurveyResponseValue,
8
+ SURVEY_LANGUAGE_PROPERTY,
9
+ surveyHasResponses,
10
+ } from './events'
11
+ export {
12
+ applySurveyTranslation,
13
+ detectSurveyLanguage,
14
+ findBestTranslationMatch,
15
+ getBaseLanguage,
16
+ getLanguageFromStoredPersonProperties,
17
+ normalizeLanguageCode,
18
+ } from './translations'
@@ -0,0 +1,205 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import {
3
+ applySurveyTranslation,
4
+ detectSurveyLanguage,
5
+ findBestTranslationMatch,
6
+ getLanguageFromStoredPersonProperties,
7
+ } from './translations'
8
+ import { Survey, SurveyQuestionType, SurveyType } from '../types'
9
+
10
+ const createBaseSurvey = (): Survey => ({
11
+ id: 'test-survey',
12
+ name: 'Test Survey',
13
+ description: 'Test Description',
14
+ type: SurveyType.Popover,
15
+ questions: [
16
+ {
17
+ type: SurveyQuestionType.Open,
18
+ question: 'What do you think?',
19
+ id: 'q1',
20
+ originalQuestionIndex: 0,
21
+ },
22
+ ],
23
+ appearance: {
24
+ thankYouMessageHeader: 'Thank you!',
25
+ thankYouMessageDescription: 'We appreciate your feedback',
26
+ thankYouMessageCloseButtonText: 'Close',
27
+ },
28
+ })
29
+
30
+ describe('survey translations', () => {
31
+ describe('detectSurveyLanguage', () => {
32
+ it.each([
33
+ {
34
+ name: 'prioritizes override language',
35
+ input: {
36
+ overrideLanguage: 'de',
37
+ storedPersonProperties: { language: 'es' },
38
+ locale: 'fr',
39
+ },
40
+ expected: 'de',
41
+ },
42
+ {
43
+ name: 'uses person language when override is missing',
44
+ input: {
45
+ storedPersonProperties: { language: 'es' },
46
+ locale: 'fr',
47
+ },
48
+ expected: 'es',
49
+ },
50
+ {
51
+ name: 'falls back to locale',
52
+ input: {
53
+ storedPersonProperties: { some_other_property: 'value' },
54
+ locale: 'fr-CA',
55
+ },
56
+ expected: 'fr-CA',
57
+ },
58
+ {
59
+ name: 'returns null when no sources exist',
60
+ input: {
61
+ storedPersonProperties: { some_other_property: 'value' },
62
+ },
63
+ expected: null,
64
+ },
65
+ {
66
+ name: 'trims override language',
67
+ input: {
68
+ overrideLanguage: ' es ',
69
+ },
70
+ expected: 'es',
71
+ },
72
+ ])('$name', ({ input, expected }) => {
73
+ expect(detectSurveyLanguage(input)).toBe(expected)
74
+ })
75
+
76
+ it('reads language from stored person properties', () => {
77
+ expect(getLanguageFromStoredPersonProperties({ language: 'it' })).toBe('it')
78
+ expect(getLanguageFromStoredPersonProperties({ language: ' ' })).toBeNull()
79
+ expect(getLanguageFromStoredPersonProperties({})).toBeNull()
80
+ })
81
+ })
82
+
83
+ describe('findBestTranslationMatch', () => {
84
+ it('supports exact and base-language matches', () => {
85
+ expect(findBestTranslationMatch({ fr: {}, 'fr-CA': {} }, 'FR-ca')).toBe('fr-CA')
86
+ expect(findBestTranslationMatch({ fr: {} }, 'fr-CA')).toBe('fr')
87
+ expect(findBestTranslationMatch({ es: {} }, 'de')).toBeNull()
88
+ })
89
+ })
90
+
91
+ describe('applySurveyTranslation', () => {
92
+ it('returns original survey when no translations exist', () => {
93
+ const survey = createBaseSurvey()
94
+ const result = applySurveyTranslation(survey, 'fr')
95
+
96
+ expect(result.survey).toEqual(survey)
97
+ expect(result.matchedKey).toBeNull()
98
+ })
99
+
100
+ it('applies survey-level translations', () => {
101
+ const survey = createBaseSurvey()
102
+ survey.translations = {
103
+ fr: {
104
+ name: 'Enquete Test',
105
+ thankYouMessageHeader: 'Merci',
106
+ thankYouMessageDescription: 'Merci pour votre retour',
107
+ thankYouMessageCloseButtonText: 'Fermer',
108
+ },
109
+ }
110
+
111
+ const result = applySurveyTranslation(survey, 'fr')
112
+
113
+ expect(result.survey.name).toBe('Enquete Test')
114
+ expect(result.survey.appearance?.thankYouMessageHeader).toBe('Merci')
115
+ expect(result.survey.appearance?.thankYouMessageDescription).toBe('Merci pour votre retour')
116
+ expect(result.survey.appearance?.thankYouMessageCloseButtonText).toBe('Fermer')
117
+ expect(result.matchedKey).toBe('fr')
118
+ })
119
+
120
+ it('applies question-level translations', () => {
121
+ const survey = createBaseSurvey()
122
+ survey.questions = [
123
+ {
124
+ type: SurveyQuestionType.Rating,
125
+ question: 'How was it?',
126
+ id: 'q1',
127
+ originalQuestionIndex: 0,
128
+ display: 'number',
129
+ scale: 5,
130
+ lowerBoundLabel: 'Bad',
131
+ upperBoundLabel: 'Great',
132
+ translations: {
133
+ pt: {
134
+ question: 'Como foi?',
135
+ lowerBoundLabel: 'Ruim',
136
+ upperBoundLabel: 'Otimo',
137
+ },
138
+ },
139
+ },
140
+ {
141
+ type: SurveyQuestionType.MultipleChoice,
142
+ question: 'Pick one',
143
+ id: 'q2',
144
+ originalQuestionIndex: 1,
145
+ choices: ['One', 'Other'],
146
+ hasOpenChoice: true,
147
+ translations: {
148
+ pt: {
149
+ choices: ['Um', 'Outro'],
150
+ },
151
+ },
152
+ },
153
+ ]
154
+
155
+ const result = applySurveyTranslation(survey, 'pt-BR')
156
+
157
+ expect(result.survey.questions[0].question).toBe('Como foi?')
158
+ expect('lowerBoundLabel' in result.survey.questions[0] && result.survey.questions[0].lowerBoundLabel).toBe('Ruim')
159
+ expect('upperBoundLabel' in result.survey.questions[0] && result.survey.questions[0].upperBoundLabel).toBe(
160
+ 'Otimo'
161
+ )
162
+ expect('choices' in result.survey.questions[1] && result.survey.questions[1].choices).toEqual(['Um', 'Outro'])
163
+ expect(result.matchedKey).toBe('pt')
164
+ })
165
+
166
+ it('uses question-level match when there is no survey-level translation', () => {
167
+ const survey = createBaseSurvey()
168
+ survey.questions[0].translations = {
169
+ de: {
170
+ question: 'Was denkst du?',
171
+ },
172
+ }
173
+
174
+ const result = applySurveyTranslation(survey, 'de')
175
+
176
+ expect(result.survey.questions[0].question).toBe('Was denkst du?')
177
+ expect(result.matchedKey).toBe('de')
178
+ })
179
+
180
+ it('preserves custom survey and question fields for shared consumers', () => {
181
+ const survey = {
182
+ name: 'Custom Survey',
183
+ customSurveyField: true,
184
+ questions: [
185
+ {
186
+ question: 'Pick one',
187
+ customQuestionField: 'native-renderer',
188
+ translations: {
189
+ fr: {
190
+ question: 'Choisissez une option',
191
+ },
192
+ },
193
+ },
194
+ ],
195
+ }
196
+
197
+ const result = applySurveyTranslation(survey, 'fr')
198
+
199
+ expect(result.survey.customSurveyField).toBe(true)
200
+ expect(result.survey.questions[0].question).toBe('Choisissez une option')
201
+ expect(result.survey.questions[0].customQuestionField).toBe('native-renderer')
202
+ expect(result.matchedKey).toBe('fr')
203
+ })
204
+ })
205
+ })