@posthog/core 1.49.2 → 1.50.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.
@@ -0,0 +1,166 @@
1
+ import { isArray, isBoolean, isNull, isNullish, isUndefined } from "./type-utils.mjs";
2
+ import { CIRCULAR_VALUE, FUNCTION_VALUE, MAX_JSON_SAFE_VALUE_DEPTH, MAX_JSON_SAFE_VALUE_ITEMS, MAX_JSON_SAFE_VALUE_NODES, TRUNCATED_VALUE, UNSERIALIZABLE_VALUE, sanitizeString } from "./json-utils.mjs";
3
+ const INT64_RANGE_LIMIT = 9223372036854775808;
4
+ const INT64_RANGE_LIMIT_DECIMAL = '9223372036854775808';
5
+ const propertyIsEnumerable = Object.prototype.propertyIsEnumerable;
6
+ function newState() {
7
+ return {
8
+ ancestors: new WeakSet(),
9
+ remainingNodes: MAX_JSON_SAFE_VALUE_NODES
10
+ };
11
+ }
12
+ function toOtlpAnyValue(value, logger) {
13
+ try {
14
+ return encodeAnyValue(value, logger, newState(), 0);
15
+ } catch {
16
+ return {
17
+ stringValue: UNSERIALIZABLE_VALUE
18
+ };
19
+ }
20
+ }
21
+ function toOtlpKeyValueList(attrs, logger) {
22
+ try {
23
+ return encodeKeyValueList(attrs, logger, newState(), 0);
24
+ } catch {
25
+ return [];
26
+ }
27
+ }
28
+ function encodeBigInt(value, logger) {
29
+ const decimal = value.toString();
30
+ const limit = BigInt(INT64_RANGE_LIMIT_DECIMAL);
31
+ if (value >= limit || value < -limit) {
32
+ logger?.debug(`Attribute ${decimal} is outside the int64 range; encoding it as a string`);
33
+ return {
34
+ stringValue: decimal
35
+ };
36
+ }
37
+ return {
38
+ intValue: decimal
39
+ };
40
+ }
41
+ function encodeAnyValue(value, logger, state, depth) {
42
+ if (state.remainingNodes <= 0) return {
43
+ stringValue: TRUNCATED_VALUE
44
+ };
45
+ state.remainingNodes--;
46
+ if (isBoolean(value)) return {
47
+ boolValue: value
48
+ };
49
+ if ('bigint' == typeof value) return encodeBigInt(value, logger);
50
+ if ('number' == typeof value) {
51
+ if (!Number.isFinite(value)) return {
52
+ stringValue: String(value)
53
+ };
54
+ if (Number.isInteger(value)) {
55
+ if (Number.isSafeInteger(value)) return {
56
+ intValue: String(value)
57
+ };
58
+ if ('undefined' == typeof BigInt) return {
59
+ stringValue: String(value)
60
+ };
61
+ const decimal = BigInt(value).toString();
62
+ if (value >= INT64_RANGE_LIMIT || value < -INT64_RANGE_LIMIT) {
63
+ logger?.debug(`Attribute ${decimal} is outside the int64 range; encoding it as a string`);
64
+ return {
65
+ stringValue: decimal
66
+ };
67
+ }
68
+ return {
69
+ intValue: decimal
70
+ };
71
+ }
72
+ return {
73
+ doubleValue: value
74
+ };
75
+ }
76
+ if ('string' == typeof value) return {
77
+ stringValue: sanitizeString(value)
78
+ };
79
+ if ('function' == typeof value) return {
80
+ stringValue: FUNCTION_VALUE
81
+ };
82
+ if ('symbol' == typeof value) return {
83
+ stringValue: String(value)
84
+ };
85
+ if ('object' == typeof value && null !== value) {
86
+ if (state.ancestors.has(value)) return {
87
+ stringValue: CIRCULAR_VALUE
88
+ };
89
+ if (depth >= MAX_JSON_SAFE_VALUE_DEPTH) return {
90
+ stringValue: TRUNCATED_VALUE
91
+ };
92
+ if (value instanceof Date) {
93
+ const time = value.getTime();
94
+ const iso = Number.isFinite(time) ? value.toISOString() : String(value);
95
+ return {
96
+ stringValue: 'string' == typeof iso ? sanitizeString(iso) : String(iso)
97
+ };
98
+ }
99
+ state.ancestors.add(value);
100
+ try {
101
+ try {
102
+ const toJSON = value.toJSON;
103
+ if ('function' == typeof toJSON) return encodeAnyValue(toJSON.call(value), logger, state, depth + 1);
104
+ } catch {}
105
+ if (isArray(value)) return {
106
+ arrayValue: {
107
+ values: encodeArrayValues(value, logger, state, depth + 1)
108
+ }
109
+ };
110
+ return {
111
+ kvlistValue: {
112
+ values: encodeKeyValueList(value, logger, state, depth + 1)
113
+ }
114
+ };
115
+ } finally{
116
+ state.ancestors.delete(value);
117
+ }
118
+ }
119
+ return {
120
+ stringValue: sanitizeString(String(value))
121
+ };
122
+ }
123
+ function encodeArrayValues(values, logger, state, depth) {
124
+ const result = [];
125
+ const itemCount = Math.min(values.length, MAX_JSON_SAFE_VALUE_ITEMS);
126
+ let index = 0;
127
+ for(; index < itemCount && state.remainingNodes > 0; index++)try {
128
+ const element = index in values ? values[index] : void 0;
129
+ if (isNullish(element)) continue;
130
+ result.push(encodeAnyValue(element, logger, state, depth));
131
+ } catch {
132
+ result.push({
133
+ stringValue: UNSERIALIZABLE_VALUE
134
+ });
135
+ }
136
+ if (values.length > index) result.push({
137
+ stringValue: TRUNCATED_VALUE
138
+ });
139
+ return result;
140
+ }
141
+ function encodeKeyValueList(attrs, logger, state, depth) {
142
+ const result = [];
143
+ for(const key in attrs)if (propertyIsEnumerable.call(attrs, key)) {
144
+ if (result.length >= MAX_JSON_SAFE_VALUE_ITEMS || state.remainingNodes <= 0) {
145
+ logger?.debug('Attributes truncated: the value exceeds the OTLP encoder budget');
146
+ break;
147
+ }
148
+ try {
149
+ const value = attrs[key];
150
+ if (isNull(value) || isUndefined(value)) continue;
151
+ result.push({
152
+ key: sanitizeString(key),
153
+ value: encodeAnyValue(value, logger, state, depth)
154
+ });
155
+ } catch {
156
+ result.push({
157
+ key: sanitizeString(key),
158
+ value: {
159
+ stringValue: UNSERIALIZABLE_VALUE
160
+ }
161
+ });
162
+ }
163
+ }
164
+ return result;
165
+ }
166
+ export { toOtlpAnyValue, toOtlpKeyValueList };
@@ -8,10 +8,10 @@ export interface BrowserDetectionHints {
8
8
  brave?: boolean;
9
9
  }
10
10
  /**
11
- * Opt-in tweaks to UA-string detection. These change how existing traffic is
12
- * attributed, so the host SDK gates them (behind its `2026-05-30` config
13
- * defaults) rather than enabling them unconditionally turning one on
14
- * reattributes browsers that were previously reported as something else.
11
+ * Opt-in tweaks to UA-string detection. Turning one on reattributes browsers
12
+ * that were previously reported as something else, so the host SDK gates
13
+ * shifts big enough to move users' metrics behind its `2026-05-30` config
14
+ * defaults. Smaller reattributions ship unconditionally in `detectBrowser`.
15
15
  */
16
16
  export interface BrowserDetectionOptions {
17
17
  detectGoogleSearchApp?: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"user-agent-utils.d.ts","sourceRoot":"","sources":["../../src/utils/user-agent-utils.ts"],"names":[],"mappings":"AAgEA;;;;;GAKG;AACH,MAAM,WAAW,qBAAqB;IAGpC,KAAK,CAAC,EAAE,OAAO,CAAA;CAChB;AASD;;;;;GAKG;AACH,MAAM,WAAW,uBAAuB;IAGtC,qBAAqB,CAAC,EAAE,OAAO,CAAA;CAChC;AAgCD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,aAAa,GACxB,YAAY,MAAM,EAClB,QAAQ,MAAM,GAAG,SAAS,EAC1B,QAAQ,qBAAqB,EAC7B,UAAU,uBAAuB,KAChC,MA2FF,CAAA;AAmCD;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAC/B,WAAW,MAAM,EACjB,QAAQ,MAAM,GAAG,SAAS,EAC1B,QAAQ,qBAAqB,EAC7B,UAAU,uBAAuB,KAChC,MAAM,GAAG,IAmBX,CAAA;AA0FD,eAAO,MAAM,QAAQ,GAAa,YAAY,MAAM,KAAG,CAAC,MAAM,EAAE,MAAM,CAUrE,CAAA;AAED,eAAO,MAAM,YAAY,GAAa,YAAY,MAAM,KAAG,MAuD1D,CAAA;AAED,eAAO,MAAM,gBAAgB,GAC3B,YAAY,MAAM,EAClB,UAAU;IACR,qBAAqB,CAAC,EAAE,MAAM,CAAA;IAC9B,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAC1B,KACA,MA4BF,CAAA"}
1
+ {"version":3,"file":"user-agent-utils.d.ts","sourceRoot":"","sources":["../../src/utils/user-agent-utils.ts"],"names":[],"mappings":"AAsEA;;;;;GAKG;AACH,MAAM,WAAW,qBAAqB;IAGpC,KAAK,CAAC,EAAE,OAAO,CAAA;CAChB;AASD;;;;;GAKG;AACH,MAAM,WAAW,uBAAuB;IAGtC,qBAAqB,CAAC,EAAE,OAAO,CAAA;CAChC;AAgCD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,aAAa,GACxB,YAAY,MAAM,EAClB,QAAQ,MAAM,GAAG,SAAS,EAC1B,QAAQ,qBAAqB,EAC7B,UAAU,uBAAuB,KAChC,MAkGF,CAAA;AAsCD;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAC/B,WAAW,MAAM,EACjB,QAAQ,MAAM,GAAG,SAAS,EAC1B,QAAQ,qBAAqB,EAC7B,UAAU,uBAAuB,KAChC,MAAM,GAAG,IAmBX,CAAA;AA0FD,eAAO,MAAM,QAAQ,GAAa,YAAY,MAAM,KAAG,CAAC,MAAM,EAAE,MAAM,CAUrE,CAAA;AAED,eAAO,MAAM,YAAY,GAAa,YAAY,MAAM,KAAG,MAuD1D,CAAA;AAED,eAAO,MAAM,gBAAgB,GAC3B,YAAY,MAAM,EAClB,UAAU;IACR,qBAAqB,CAAC,EAAE,MAAM,CAAA;IAC9B,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAC1B,KACA,MA4BF,CAAA"}
@@ -78,9 +78,13 @@ const DUCKDUCKGO = 'DuckDuckGo';
78
78
  const PALE_MOON = 'Pale Moon';
79
79
  const WATERFOX = 'Waterfox';
80
80
  const BRAVE = 'Brave';
81
+ const CLAUDE = 'Claude';
82
+ const CODEX = 'Codex';
83
+ const CHATGPT = 'ChatGPT';
81
84
  const GOOGLE_SEARCH_APP = 'Google Search App';
82
85
  const BROWSER_VERSION_REGEX_SUFFIX = '(\\d+(\\.\\d+)?)';
83
86
  const DEFAULT_BROWSER_VERSION_REGEX = new RegExp('Version/' + BROWSER_VERSION_REGEX_SUFFIX);
87
+ const AI_APP_VERSION_REGEX = new RegExp('(' + CLAUDE + '|' + CODEX + '|' + CHATGPT + ')\\/' + BROWSER_VERSION_REGEX_SUFFIX);
84
88
  function browserFromHints(hints) {
85
89
  if (hints?.brave) return BRAVE;
86
90
  return null;
@@ -122,6 +126,9 @@ const detectBrowser = function(user_agent, vendor, hints, options) {
122
126
  else if ((0, external_string_utils_js_namespaceObject.includes)(user_agent, 'YaBrowser/')) return YANDEX;
123
127
  else if ((0, external_string_utils_js_namespaceObject.includes)(user_agent, WHALE + '/')) return WHALE;
124
128
  else if ((0, external_string_utils_js_namespaceObject.includes)(user_agent, DUCKDUCKGO + '/') || (0, external_string_utils_js_namespaceObject.includes)(user_agent, 'Ddg/')) return DUCKDUCKGO;
129
+ else if ((0, external_string_utils_js_namespaceObject.includes)(user_agent, CLAUDE + '/')) return CLAUDE;
130
+ else if ((0, external_string_utils_js_namespaceObject.includes)(user_agent, CODEX + '/')) return CODEX;
131
+ else if ((0, external_string_utils_js_namespaceObject.includes)(user_agent, CHATGPT + '/')) return CHATGPT;
125
132
  else if ((0, external_string_utils_js_namespaceObject.includes)(user_agent, 'FBIOS')) return FACEBOOK + ' ' + MOBILE;
126
133
  else if ((0, external_string_utils_js_namespaceObject.includes)(user_agent, 'UCWEB') || (0, external_string_utils_js_namespaceObject.includes)(user_agent, 'UCBrowser')) return 'UC Browser';
127
134
  else if ((0, external_string_utils_js_namespaceObject.includes)(user_agent, 'CriOS')) return CHROME_IOS;
@@ -198,6 +205,15 @@ const versionRegexes = {
198
205
  [BRAVE]: [
199
206
  new RegExp(BRAVE + '\\/' + BROWSER_VERSION_REGEX_SUFFIX)
200
207
  ],
208
+ [CLAUDE]: [
209
+ AI_APP_VERSION_REGEX
210
+ ],
211
+ [CODEX]: [
212
+ AI_APP_VERSION_REGEX
213
+ ],
214
+ [CHATGPT]: [
215
+ AI_APP_VERSION_REGEX
216
+ ],
201
217
  [DUCKDUCKGO]: [
202
218
  new RegExp('(DuckDuckGo|Ddg)\\/' + BROWSER_VERSION_REGEX_SUFFIX)
203
219
  ],
@@ -46,9 +46,13 @@ const DUCKDUCKGO = 'DuckDuckGo';
46
46
  const PALE_MOON = 'Pale Moon';
47
47
  const WATERFOX = 'Waterfox';
48
48
  const BRAVE = 'Brave';
49
+ const CLAUDE = 'Claude';
50
+ const CODEX = 'Codex';
51
+ const CHATGPT = 'ChatGPT';
49
52
  const GOOGLE_SEARCH_APP = 'Google Search App';
50
53
  const BROWSER_VERSION_REGEX_SUFFIX = '(\\d+(\\.\\d+)?)';
51
54
  const DEFAULT_BROWSER_VERSION_REGEX = new RegExp('Version/' + BROWSER_VERSION_REGEX_SUFFIX);
55
+ const AI_APP_VERSION_REGEX = new RegExp('(' + CLAUDE + '|' + CODEX + '|' + CHATGPT + ')\\/' + BROWSER_VERSION_REGEX_SUFFIX);
52
56
  function browserFromHints(hints) {
53
57
  if (hints?.brave) return BRAVE;
54
58
  return null;
@@ -90,6 +94,9 @@ const detectBrowser = function(user_agent, vendor, hints, options) {
90
94
  else if (includes(user_agent, 'YaBrowser/')) return YANDEX;
91
95
  else if (includes(user_agent, WHALE + '/')) return WHALE;
92
96
  else if (includes(user_agent, DUCKDUCKGO + '/') || includes(user_agent, 'Ddg/')) return DUCKDUCKGO;
97
+ else if (includes(user_agent, CLAUDE + '/')) return CLAUDE;
98
+ else if (includes(user_agent, CODEX + '/')) return CODEX;
99
+ else if (includes(user_agent, CHATGPT + '/')) return CHATGPT;
93
100
  else if (includes(user_agent, 'FBIOS')) return FACEBOOK + ' ' + MOBILE;
94
101
  else if (includes(user_agent, 'UCWEB') || includes(user_agent, 'UCBrowser')) return 'UC Browser';
95
102
  else if (includes(user_agent, 'CriOS')) return CHROME_IOS;
@@ -166,6 +173,15 @@ const versionRegexes = {
166
173
  [BRAVE]: [
167
174
  new RegExp(BRAVE + '\\/' + BROWSER_VERSION_REGEX_SUFFIX)
168
175
  ],
176
+ [CLAUDE]: [
177
+ AI_APP_VERSION_REGEX
178
+ ],
179
+ [CODEX]: [
180
+ AI_APP_VERSION_REGEX
181
+ ],
182
+ [CHATGPT]: [
183
+ AI_APP_VERSION_REGEX
184
+ ],
169
185
  [DUCKDUCKGO]: [
170
186
  new RegExp('(DuckDuckGo|Ddg)\\/' + BROWSER_VERSION_REGEX_SUFFIX)
171
187
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/core",
3
- "version": "1.49.2",
3
+ "version": "1.50.0",
4
4
  "bugs": {
5
5
  "url": "https://github.com/PostHog/posthog-js/issues"
6
6
  },
@@ -1,7 +1,7 @@
1
1
  import { parsePayload } from './featureFlagUtils'
2
2
  import type { FeatureFlagValue, JsonType } from './types'
3
3
 
4
- export type FeatureFlagPropertyValue = string | number | (string | number)[] | boolean
4
+ export type FeatureFlagPropertyValue = JsonType
5
5
 
6
6
  export type FeatureFlagProperty = {
7
7
  key: string
@@ -42,6 +42,172 @@ export class InconclusiveMatchError extends Error {
42
42
  }
43
43
  }
44
44
 
45
+ function isTruthyOrFalsyPropertyValue(value: unknown): boolean {
46
+ if (typeof value === 'boolean') return true
47
+ if (typeof value === 'string') {
48
+ const lowercaseValue = value.toLowerCase()
49
+ return lowercaseValue === 'true' || lowercaseValue === 'false'
50
+ }
51
+ if (!Array.isArray(value)) return false
52
+ for (let index = 0; index < value.length; index++) {
53
+ if (!isTruthyOrFalsyPropertyValue(index in value ? value[index] : null)) return false
54
+ }
55
+ return true
56
+ }
57
+
58
+ function isTruthyPropertyValue(value: unknown): boolean {
59
+ if (typeof value === 'boolean') return value
60
+ if (typeof value === 'string') return value.toLowerCase() === 'true'
61
+ if (!Array.isArray(value)) return false
62
+ for (let index = 0; index < value.length; index++) {
63
+ if (!isTruthyPropertyValue(index in value ? value[index] : null)) return false
64
+ }
65
+ return true
66
+ }
67
+
68
+ function assertUnicodeScalarString(value: string): void {
69
+ for (let index = 0; index < value.length; index++) {
70
+ const unit = value.charCodeAt(index)
71
+ if (unit >= 0xd800 && unit <= 0xdbff) {
72
+ const next = value.charCodeAt(index + 1)
73
+ if (index + 1 >= value.length || next < 0xdc00 || next > 0xdfff) {
74
+ throw new InconclusiveMatchError('Cannot stringify an unpaired surrogate like the flags service')
75
+ }
76
+ index++
77
+ } else if (unit >= 0xdc00 && unit <= 0xdfff) {
78
+ throw new InconclusiveMatchError('Cannot stringify an unpaired surrogate like the flags service')
79
+ }
80
+ }
81
+ }
82
+
83
+ function assertJsonRepresentable(value: unknown, seen: Set<object> = new Set()): void {
84
+ if (value === null || typeof value === 'boolean') return
85
+ if (typeof value === 'string') {
86
+ assertUnicodeScalarString(value)
87
+ return
88
+ }
89
+ if (typeof value === 'number') {
90
+ if (!Number.isFinite(value)) {
91
+ throw new InconclusiveMatchError(`Cannot represent non-finite number ${value} like the flags service`)
92
+ }
93
+ return
94
+ }
95
+ if (Array.isArray(value)) {
96
+ if (seen.has(value)) throw new InconclusiveMatchError('Cannot represent a circular array during local evaluation')
97
+ seen.add(value)
98
+ try {
99
+ for (let index = 0; index < value.length; index++) {
100
+ if (index in value) assertJsonRepresentable(value[index], seen)
101
+ }
102
+ } finally {
103
+ seen.delete(value)
104
+ }
105
+ return
106
+ }
107
+ if (typeof value === 'object') {
108
+ const prototype = Object.getPrototypeOf(value)
109
+ if (prototype !== Object.prototype && prototype !== null) {
110
+ throw new InconclusiveMatchError('Cannot represent a non-JSON object like the flags service')
111
+ }
112
+ if (seen.has(value)) throw new InconclusiveMatchError('Cannot represent a circular object during local evaluation')
113
+ seen.add(value)
114
+ try {
115
+ for (const key of Object.keys(value)) {
116
+ assertUnicodeScalarString(key)
117
+ assertJsonRepresentable((value as Record<string, unknown>)[key], seen)
118
+ }
119
+ } finally {
120
+ seen.delete(value)
121
+ }
122
+ return
123
+ }
124
+ throw new InconclusiveMatchError(`Cannot represent ${typeof value} like the flags service`)
125
+ }
126
+
127
+ function compareJsonObjectKeys(left: string, right: string): number {
128
+ let leftIndex = 0
129
+ let rightIndex = 0
130
+ while (leftIndex < left.length && rightIndex < right.length) {
131
+ const leftUnit = left.charCodeAt(leftIndex)
132
+ const rightUnit = right.charCodeAt(rightIndex)
133
+ const leftIsHighSurrogate = leftUnit >= 0xd800 && leftUnit <= 0xdbff
134
+ const rightIsHighSurrogate = rightUnit >= 0xd800 && rightUnit <= 0xdbff
135
+ const leftNext = leftIsHighSurrogate ? left.charCodeAt(leftIndex + 1) : 0
136
+ const rightNext = rightIsHighSurrogate ? right.charCodeAt(rightIndex + 1) : 0
137
+
138
+ const leftCodePoint = leftIsHighSurrogate ? (leftUnit - 0xd800) * 0x400 + leftNext - 0xdc00 + 0x10000 : leftUnit
139
+ const rightCodePoint = rightIsHighSurrogate
140
+ ? (rightUnit - 0xd800) * 0x400 + rightNext - 0xdc00 + 0x10000
141
+ : rightUnit
142
+ if (leftCodePoint !== rightCodePoint) return leftCodePoint - rightCodePoint
143
+ leftIndex += leftIsHighSurrogate ? 2 : 1
144
+ rightIndex += rightIsHighSurrogate ? 2 : 1
145
+ }
146
+ return left.length - right.length
147
+ }
148
+
149
+ // JavaScript collapses JSON integers and integral floats into Number, including inside composites.
150
+ // These values fall back rather than choosing a spelling that can disagree with the flags service.
151
+ function serializeJsonValue(value: unknown, seen: Set<object> = new Set()): string {
152
+ if (value === null) return 'null'
153
+ if (typeof value === 'string') {
154
+ assertUnicodeScalarString(value)
155
+ return JSON.stringify(value)
156
+ }
157
+ if (typeof value === 'boolean') return value ? 'true' : 'false'
158
+ if (typeof value === 'number') {
159
+ if (!Number.isFinite(value)) {
160
+ throw new InconclusiveMatchError(`Cannot stringify non-finite number ${value} like the flags service`)
161
+ }
162
+ if (Number.isInteger(value)) {
163
+ throw new InconclusiveMatchError(
164
+ `Cannot distinguish integer ${value} from an integral JSON float during local evaluation`
165
+ )
166
+ }
167
+ return String(value)
168
+ }
169
+ if (Array.isArray(value)) {
170
+ if (seen.has(value)) throw new InconclusiveMatchError('Cannot stringify a circular array during local evaluation')
171
+ seen.add(value)
172
+ try {
173
+ const items: string[] = []
174
+ for (let index = 0; index < value.length; index++) {
175
+ items.push(index in value ? serializeJsonValue(value[index], seen) : 'null')
176
+ }
177
+ return `[${items.join(',')}]`
178
+ } finally {
179
+ seen.delete(value)
180
+ }
181
+ }
182
+ if (typeof value === 'object') {
183
+ const prototype = Object.getPrototypeOf(value)
184
+ if (prototype !== Object.prototype && prototype !== null) {
185
+ throw new InconclusiveMatchError('Cannot stringify a non-JSON object like the flags service')
186
+ }
187
+ if (seen.has(value)) throw new InconclusiveMatchError('Cannot stringify a circular object during local evaluation')
188
+ seen.add(value)
189
+ try {
190
+ const keys = Object.keys(value)
191
+ keys.forEach(assertUnicodeScalarString)
192
+ return `{${keys
193
+ .sort(compareJsonObjectKeys)
194
+ .map((key) => `${JSON.stringify(key)}:${serializeJsonValue((value as Record<string, unknown>)[key], seen)}`)
195
+ .join(',')}}`
196
+ } finally {
197
+ seen.delete(value)
198
+ }
199
+ }
200
+ throw new InconclusiveMatchError(`Cannot stringify ${typeof value} like the flags service`)
201
+ }
202
+
203
+ function exactMatchString(value: unknown): string {
204
+ if (typeof value === 'string') {
205
+ assertUnicodeScalarString(value)
206
+ return value
207
+ }
208
+ return serializeJsonValue(value)
209
+ }
210
+
45
211
  function isValidRegex(regex: string): boolean {
46
212
  try {
47
213
  new RegExp(regex)
@@ -51,6 +217,11 @@ function isValidRegex(regex: string): boolean {
51
217
  }
52
218
  }
53
219
 
220
+ // The flags service deliberately folds only ASCII for substring, prefix, and suffix operators.
221
+ function asciiLowercase(value: unknown): string {
222
+ return String(value).replace(/[A-Z]/g, (character) => character.toLowerCase())
223
+ }
224
+
54
225
  type SemverTuple = [number, number, number]
55
226
 
56
227
  function parseSemverNumericIdentifier(
@@ -190,19 +361,35 @@ export function matchFeatureFlagProperty(
190
361
  throw new InconclusiveMatchError(`Property ${key} not found in propertyValues`)
191
362
  } else if (operator === 'is_not_set') {
192
363
  return false
364
+ } else if (operator === 'is_set') {
365
+ return true
193
366
  }
194
367
 
195
368
  const overrideValue = propertyValues[key]
196
- if (overrideValue == null && !NULL_VALUES_ALLOWED_OPERATORS.includes(operator)) {
197
- options.warnFunction?.(`Property ${key} cannot have a value of null/undefined with the ${operator} operator`)
369
+ if (overrideValue === undefined) {
370
+ options.warnFunction?.(`Property ${key} cannot have a value of undefined with the ${operator} operator`)
371
+ return operator === 'is_not'
372
+ }
373
+ if (
374
+ overrideValue === null &&
375
+ !NULL_VALUES_ALLOWED_OPERATORS.includes(operator) &&
376
+ operator !== 'exact' &&
377
+ operator !== 'is_not'
378
+ ) {
379
+ options.warnFunction?.(`Property ${key} cannot have a value of null with the ${operator} operator`)
198
380
  return false
199
381
  }
200
382
 
201
- const computeExactMatch = (target: any, actual: any): boolean => {
383
+ const computeExactMatch = (target: unknown, actual: unknown): boolean => {
384
+ if (isTruthyOrFalsyPropertyValue(target)) {
385
+ assertJsonRepresentable(actual)
386
+ return isTruthyPropertyValue(target) === isTruthyPropertyValue(actual)
387
+ }
202
388
  if (Array.isArray(target)) {
203
- return target.map((item) => String(item).toLowerCase()).includes(String(actual).toLowerCase())
389
+ const actualString = exactMatchString(actual).toLowerCase()
390
+ return target.some((item) => exactMatchString(item).toLowerCase() === actualString)
204
391
  }
205
- return String(target).toLowerCase() === String(actual).toLowerCase()
392
+ return exactMatchString(target).toLowerCase() === exactMatchString(actual).toLowerCase()
206
393
  }
207
394
 
208
395
  const compare = (lhs: any, rhs: any, comparisonOperator: string): boolean => {
@@ -221,17 +408,17 @@ export function matchFeatureFlagProperty(
221
408
  case 'is_set':
222
409
  return true
223
410
  case 'icontains':
224
- return String(overrideValue).toLowerCase().includes(String(value).toLowerCase())
411
+ return asciiLowercase(overrideValue).includes(asciiLowercase(value))
225
412
  case 'not_icontains':
226
- return !String(overrideValue).toLowerCase().includes(String(value).toLowerCase())
413
+ return !asciiLowercase(overrideValue).includes(asciiLowercase(value))
227
414
  case 'starts_with':
228
- return String(overrideValue).toLowerCase().startsWith(String(value).toLowerCase())
415
+ return asciiLowercase(overrideValue).startsWith(asciiLowercase(value))
229
416
  case 'not_starts_with':
230
- return !String(overrideValue).toLowerCase().startsWith(String(value).toLowerCase())
417
+ return !asciiLowercase(overrideValue).startsWith(asciiLowercase(value))
231
418
  case 'ends_with':
232
- return String(overrideValue).toLowerCase().endsWith(String(value).toLowerCase())
419
+ return asciiLowercase(overrideValue).endsWith(asciiLowercase(value))
233
420
  case 'not_ends_with':
234
- return !String(overrideValue).toLowerCase().endsWith(String(value).toLowerCase())
421
+ return !asciiLowercase(overrideValue).endsWith(asciiLowercase(value))
235
422
  case 'regex':
236
423
  return isValidRegex(String(value)) && String(overrideValue).match(String(value)) !== null
237
424
  case 'not_regex':
package/src/index.ts CHANGED
@@ -42,9 +42,8 @@ export {
42
42
  buildResourceAttributes,
43
43
  getOtlpSeverityNumber,
44
44
  getOtlpSeverityText,
45
- toOtlpAnyValue,
46
- toOtlpKeyValueList,
47
45
  } from './logs/logs-utils'
46
+ export { toOtlpAnyValue, toOtlpKeyValueList } from './utils/otlp-any-value'
48
47
  export { PostHogLogs } from './logs'
49
48
  export type {
50
49
  BeforeSendLogFn,