@posthog/core
Advanced tools
@@ -77,3 +77,3 @@ "use strict"; | ||
| if (!this._checkRateLimit()) return; | ||
| const record = (0, external_logs_utils_js_namespaceObject.buildOtlpLogRecord)(filtered, this._getContext()); | ||
| const record = (0, external_logs_utils_js_namespaceObject.buildOtlpLogRecord)(filtered, this._getContext(), this._logger); | ||
| const entry = { | ||
@@ -80,0 +80,0 @@ record |
@@ -49,3 +49,3 @@ import { buildOtlpLogRecord, buildOtlpLogsPayload, buildResourceAttributes } from "./logs-utils.mjs"; | ||
| if (!this._checkRateLimit()) return; | ||
| const record = buildOtlpLogRecord(filtered, this._getContext()); | ||
| const record = buildOtlpLogRecord(filtered, this._getContext(), this._logger); | ||
| const entry = { | ||
@@ -52,0 +52,0 @@ record |
| import type { CaptureLogOptions, LogAttributeValue, LogSeverityLevel, OtlpAnyValue, OtlpKeyValue, OtlpLogRecord, OtlpLogsPayload, OtlpSeverityText } from '@posthog/types'; | ||
| import type { Logger } from '../types'; | ||
| import type { LogSdkContext, ResolvedPostHogLogsConfig } from './types'; | ||
| export declare function getOtlpSeverityText(level: LogSeverityLevel): OtlpSeverityText; | ||
| export declare function getOtlpSeverityNumber(level: LogSeverityLevel): number; | ||
| export declare function toOtlpAnyValue(value: LogAttributeValue): OtlpAnyValue; | ||
| export declare function toOtlpKeyValueList(attrs: Record<string, LogAttributeValue>): OtlpKeyValue[]; | ||
| export declare function toOtlpAnyValue(value: LogAttributeValue, logger?: Logger): OtlpAnyValue; | ||
| export declare function toOtlpKeyValueList(attrs: Record<string, LogAttributeValue>, logger?: Logger): OtlpKeyValue[]; | ||
| export declare function buildOtlpLogRecord(options: CaptureLogOptions, sdkContext: LogSdkContext, logger?: Logger): OtlpLogRecord; | ||
| /** | ||
| * Builds a single OTLP log record. | ||
| * | ||
| * Auto-attribute population is shape-driven: any field present on `sdkContext` | ||
| * is emitted as the corresponding attribute. Each SDK populates only the | ||
| * fields that apply to it (browser fills `currentUrl`; mobile fills | ||
| * `screenName` / `appState`), so a missing field never adds a stray attribute. | ||
| * | ||
| * User-provided `options.attributes` always wins on conflicts. | ||
| */ | ||
| export declare function buildOtlpLogRecord(options: CaptureLogOptions, sdkContext: LogSdkContext): OtlpLogRecord; | ||
| /** | ||
| * OTLP resource attributes for every batch, shared by the core flush path and | ||
@@ -20,0 +11,0 @@ * SDK-specific paths that bypass it (e.g. the browser's synchronous sendBeacon |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"logs-utils.d.ts","sourceRoot":"","sources":["../../src/logs/logs-utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,iBAAiB,EACjB,iBAAiB,EACjB,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,eAAe,EAEf,gBAAgB,EACjB,MAAM,gBAAgB,CAAA;AACvB,OAAO,KAAK,EAAE,aAAa,EAAE,yBAAyB,EAAE,MAAM,SAAS,CAAA;AAkBvE,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,gBAAgB,GAAG,gBAAgB,CAE7E;AAED,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,gBAAgB,GAAG,MAAM,CAErE;AAMD,wBAAgB,cAAc,CAAC,KAAK,EAAE,iBAAiB,GAAG,YAAY,CAiCrE;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,GAAG,YAAY,EAAE,CAU3F;AAiBD;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,iBAAiB,EAAE,UAAU,EAAE,aAAa,GAAG,aAAa,CA4DvG;AAMD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,yBAAyB,EACjC,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,GACjB,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CASnC;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,aAAa,EAAE,EAC3B,kBAAkB,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,EACrD,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,MAAM,GACnB,eAAe,CAcjB"} | ||
| {"version":3,"file":"logs-utils.d.ts","sourceRoot":"","sources":["../../src/logs/logs-utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,iBAAiB,EACjB,iBAAiB,EACjB,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,eAAe,EAEf,gBAAgB,EACjB,MAAM,gBAAgB,CAAA;AACvB,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AACtC,OAAO,KAAK,EAAE,aAAa,EAAE,yBAAyB,EAAE,MAAM,SAAS,CAAA;AA4BvE,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,gBAAgB,GAAG,gBAAgB,CAE7E;AAED,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,gBAAgB,GAAG,MAAM,CAErE;AAqBD,wBAAgB,cAAc,CAAC,KAAK,EAAE,iBAAiB,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,YAAY,CAQtF;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,YAAY,EAAE,CAM5G;AAmMD,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,iBAAiB,EAC1B,UAAU,EAAE,aAAa,EACzB,MAAM,CAAC,EAAE,MAAM,GACd,aAAa,CA+Ef;AAMD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,yBAAyB,EACjC,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,GACjB,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CASnC;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,aAAa,EAAE,EAC3B,kBAAkB,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,EACrD,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,MAAM,GACnB,eAAe,CAcjB"} |
+160
-26
@@ -36,2 +36,3 @@ "use strict"; | ||
| const index_js_namespaceObject = require("../utils/index.js"); | ||
| const json_utils_js_namespaceObject = require("../utils/json-utils.js"); | ||
| const OTLP_SEVERITY_MAP = { | ||
@@ -70,3 +71,31 @@ trace: { | ||
| } | ||
| function toOtlpAnyValue(value) { | ||
| const INT64_RANGE_LIMIT = 9223372036854775808; | ||
| const propertyIsEnumerable = Object.prototype.propertyIsEnumerable; | ||
| function newState() { | ||
| return { | ||
| ancestors: new WeakSet(), | ||
| remainingNodes: json_utils_js_namespaceObject.MAX_JSON_SAFE_VALUE_NODES | ||
| }; | ||
| } | ||
| function toOtlpAnyValue(value, logger) { | ||
| try { | ||
| return encodeAnyValue(value, logger, newState(), 0); | ||
| } catch { | ||
| return { | ||
| stringValue: json_utils_js_namespaceObject.UNSERIALIZABLE_VALUE | ||
| }; | ||
| } | ||
| } | ||
| function toOtlpKeyValueList(attrs, logger) { | ||
| try { | ||
| return encodeKeyValueList(attrs, logger, newState(), 0); | ||
| } catch { | ||
| return []; | ||
| } | ||
| } | ||
| function encodeAnyValue(value, logger, state, depth) { | ||
| if (state.remainingNodes <= 0) return { | ||
| stringValue: json_utils_js_namespaceObject.TRUNCATED_VALUE | ||
| }; | ||
| state.remainingNodes--; | ||
| if ((0, index_js_namespaceObject.isBoolean)(value)) return { | ||
@@ -79,5 +108,20 @@ boolValue: value | ||
| }; | ||
| if (Number.isInteger(value)) return { | ||
| intValue: value | ||
| }; | ||
| if (Number.isInteger(value)) { | ||
| if (Number.isSafeInteger(value)) return { | ||
| intValue: String(value) | ||
| }; | ||
| if ('undefined' == typeof BigInt) return { | ||
| stringValue: String(value) | ||
| }; | ||
| const decimal = BigInt(value).toString(); | ||
| if (value >= INT64_RANGE_LIMIT || value < -INT64_RANGE_LIMIT) { | ||
| logger?.debug(`Attribute ${decimal} is outside the int64 range; encoding it as a string`); | ||
| return { | ||
| stringValue: decimal | ||
| }; | ||
| } | ||
| return { | ||
| intValue: decimal | ||
| }; | ||
| } | ||
| return { | ||
@@ -88,34 +132,102 @@ doubleValue: value | ||
| if ('string' == typeof value) return { | ||
| stringValue: value | ||
| stringValue: (0, json_utils_js_namespaceObject.sanitizeString)(value) | ||
| }; | ||
| if ((0, index_js_namespaceObject.isArray)(value)) return { | ||
| arrayValue: { | ||
| values: value.map((v)=>toOtlpAnyValue(v)) | ||
| } | ||
| if ('function' == typeof value) return { | ||
| stringValue: json_utils_js_namespaceObject.FUNCTION_VALUE | ||
| }; | ||
| try { | ||
| return { | ||
| stringValue: JSON.stringify(value) | ||
| if ('symbol' == typeof value) return { | ||
| stringValue: String(value) | ||
| }; | ||
| if ('object' == typeof value && null !== value) { | ||
| if (state.ancestors.has(value)) return { | ||
| stringValue: json_utils_js_namespaceObject.CIRCULAR_VALUE | ||
| }; | ||
| } catch { | ||
| return { | ||
| stringValue: String(value) | ||
| if (depth >= json_utils_js_namespaceObject.MAX_JSON_SAFE_VALUE_DEPTH) return { | ||
| stringValue: json_utils_js_namespaceObject.TRUNCATED_VALUE | ||
| }; | ||
| if (value instanceof Date) { | ||
| const time = value.getTime(); | ||
| const iso = Number.isFinite(time) ? value.toISOString() : String(value); | ||
| return { | ||
| stringValue: 'string' == typeof iso ? (0, json_utils_js_namespaceObject.sanitizeString)(iso) : String(iso) | ||
| }; | ||
| } | ||
| state.ancestors.add(value); | ||
| try { | ||
| try { | ||
| const toJSON = value.toJSON; | ||
| if ('function' == typeof toJSON) return encodeAnyValue(toJSON.call(value), logger, state, depth + 1); | ||
| } catch {} | ||
| if ((0, index_js_namespaceObject.isArray)(value)) return { | ||
| arrayValue: { | ||
| values: encodeArrayValues(value, logger, state, depth + 1) | ||
| } | ||
| }; | ||
| return { | ||
| kvlistValue: { | ||
| values: encodeKeyValueList(value, logger, state, depth + 1) | ||
| } | ||
| }; | ||
| } finally{ | ||
| state.ancestors.delete(value); | ||
| } | ||
| } | ||
| return { | ||
| stringValue: (0, json_utils_js_namespaceObject.sanitizeString)(String(value)) | ||
| }; | ||
| } | ||
| function toOtlpKeyValueList(attrs) { | ||
| function encodeArrayValues(values, logger, state, depth) { | ||
| const result = []; | ||
| for(const key in attrs){ | ||
| const value = attrs[key]; | ||
| if (!((0, index_js_namespaceObject.isNull)(value) || (0, index_js_namespaceObject.isUndefined)(value))) result.push({ | ||
| key, | ||
| value: toOtlpAnyValue(value) | ||
| const itemCount = Math.min(values.length, json_utils_js_namespaceObject.MAX_JSON_SAFE_VALUE_ITEMS); | ||
| let index = 0; | ||
| for(; index < itemCount && state.remainingNodes > 0; index++)try { | ||
| const element = index in values ? values[index] : void 0; | ||
| if ((0, index_js_namespaceObject.isNullish)(element)) continue; | ||
| result.push(encodeAnyValue(element, logger, state, depth)); | ||
| } catch { | ||
| result.push({ | ||
| stringValue: json_utils_js_namespaceObject.UNSERIALIZABLE_VALUE | ||
| }); | ||
| } | ||
| if (values.length > index) result.push({ | ||
| stringValue: json_utils_js_namespaceObject.TRUNCATED_VALUE | ||
| }); | ||
| return result; | ||
| } | ||
| function encodeKeyValueList(attrs, logger, state, depth) { | ||
| const result = []; | ||
| for(const key in attrs)if (propertyIsEnumerable.call(attrs, key)) { | ||
| if (result.length >= json_utils_js_namespaceObject.MAX_JSON_SAFE_VALUE_ITEMS || state.remainingNodes <= 0) { | ||
| logger?.debug('Attributes truncated: the value exceeds the OTLP encoder budget'); | ||
| break; | ||
| } | ||
| try { | ||
| const value = attrs[key]; | ||
| if ((0, index_js_namespaceObject.isNull)(value) || (0, index_js_namespaceObject.isUndefined)(value)) continue; | ||
| result.push({ | ||
| key: (0, json_utils_js_namespaceObject.sanitizeString)(key), | ||
| value: encodeAnyValue(value, logger, state, depth) | ||
| }); | ||
| } catch { | ||
| result.push({ | ||
| key: (0, json_utils_js_namespaceObject.sanitizeString)(key), | ||
| value: { | ||
| stringValue: json_utils_js_namespaceObject.UNSERIALIZABLE_VALUE | ||
| } | ||
| }); | ||
| } | ||
| } | ||
| return result; | ||
| } | ||
| function timestampToUnixNano() { | ||
| return String(Date.now()) + '000000'; | ||
| } | ||
| function buildOtlpLogRecord(options, sdkContext) { | ||
| function encodeBody(body) { | ||
| try { | ||
| return (0, json_utils_js_namespaceObject.sanitizeString)(String(body)); | ||
| } catch { | ||
| return json_utils_js_namespaceObject.UNSERIALIZABLE_VALUE; | ||
| } | ||
| } | ||
| function buildOtlpLogRecord(options, sdkContext, logger) { | ||
| const level = options.level || 'info'; | ||
@@ -135,5 +247,27 @@ const { text: severityText, number: severityNumber } = OTLP_SEVERITY_MAP[level] || DEFAULT_OTLP_SEVERITY; | ||
| const mergedAttributes = { | ||
| ...autoAttributes, | ||
| ...options.attributes || {} | ||
| ...autoAttributes | ||
| }; | ||
| const userAttributes = options.attributes; | ||
| if (userAttributes) { | ||
| let keys = []; | ||
| try { | ||
| keys = Object.keys(userAttributes); | ||
| } catch { | ||
| keys = []; | ||
| } | ||
| for (const key of keys){ | ||
| let value; | ||
| try { | ||
| value = userAttributes[key]; | ||
| } catch { | ||
| value = json_utils_js_namespaceObject.UNSERIALIZABLE_VALUE; | ||
| } | ||
| Object.defineProperty(mergedAttributes, key, { | ||
| value, | ||
| enumerable: true, | ||
| writable: true, | ||
| configurable: true | ||
| }); | ||
| } | ||
| } | ||
| const record = { | ||
@@ -145,5 +279,5 @@ timeUnixNano: now, | ||
| body: { | ||
| stringValue: options.body | ||
| stringValue: encodeBody(options.body) | ||
| }, | ||
| attributes: toOtlpKeyValueList(mergedAttributes) | ||
| attributes: toOtlpKeyValueList(mergedAttributes, logger) | ||
| }; | ||
@@ -150,0 +284,0 @@ if (options.trace_id) record.traceId = options.trace_id; |
+160
-26
| import { isArray, isBoolean, isNull, isNullish, isUndefined } from "../utils/index.mjs"; | ||
| 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 "../utils/json-utils.mjs"; | ||
| const OTLP_SEVERITY_MAP = { | ||
@@ -35,3 +36,31 @@ trace: { | ||
| } | ||
| function toOtlpAnyValue(value) { | ||
| const INT64_RANGE_LIMIT = 9223372036854775808; | ||
| const propertyIsEnumerable = Object.prototype.propertyIsEnumerable; | ||
| function newState() { | ||
| return { | ||
| ancestors: new WeakSet(), | ||
| remainingNodes: MAX_JSON_SAFE_VALUE_NODES | ||
| }; | ||
| } | ||
| function toOtlpAnyValue(value, logger) { | ||
| try { | ||
| return encodeAnyValue(value, logger, newState(), 0); | ||
| } catch { | ||
| return { | ||
| stringValue: UNSERIALIZABLE_VALUE | ||
| }; | ||
| } | ||
| } | ||
| function toOtlpKeyValueList(attrs, logger) { | ||
| try { | ||
| return encodeKeyValueList(attrs, logger, newState(), 0); | ||
| } catch { | ||
| return []; | ||
| } | ||
| } | ||
| function encodeAnyValue(value, logger, state, depth) { | ||
| if (state.remainingNodes <= 0) return { | ||
| stringValue: TRUNCATED_VALUE | ||
| }; | ||
| state.remainingNodes--; | ||
| if (isBoolean(value)) return { | ||
@@ -44,5 +73,20 @@ boolValue: value | ||
| }; | ||
| if (Number.isInteger(value)) return { | ||
| intValue: value | ||
| }; | ||
| if (Number.isInteger(value)) { | ||
| if (Number.isSafeInteger(value)) return { | ||
| intValue: String(value) | ||
| }; | ||
| if ('undefined' == typeof BigInt) return { | ||
| stringValue: String(value) | ||
| }; | ||
| const decimal = BigInt(value).toString(); | ||
| if (value >= INT64_RANGE_LIMIT || value < -INT64_RANGE_LIMIT) { | ||
| logger?.debug(`Attribute ${decimal} is outside the int64 range; encoding it as a string`); | ||
| return { | ||
| stringValue: decimal | ||
| }; | ||
| } | ||
| return { | ||
| intValue: decimal | ||
| }; | ||
| } | ||
| return { | ||
@@ -53,34 +97,102 @@ doubleValue: value | ||
| if ('string' == typeof value) return { | ||
| stringValue: value | ||
| stringValue: sanitizeString(value) | ||
| }; | ||
| if (isArray(value)) return { | ||
| arrayValue: { | ||
| values: value.map((v)=>toOtlpAnyValue(v)) | ||
| } | ||
| if ('function' == typeof value) return { | ||
| stringValue: FUNCTION_VALUE | ||
| }; | ||
| try { | ||
| return { | ||
| stringValue: JSON.stringify(value) | ||
| if ('symbol' == typeof value) return { | ||
| stringValue: String(value) | ||
| }; | ||
| if ('object' == typeof value && null !== value) { | ||
| if (state.ancestors.has(value)) return { | ||
| stringValue: CIRCULAR_VALUE | ||
| }; | ||
| } catch { | ||
| return { | ||
| stringValue: String(value) | ||
| if (depth >= MAX_JSON_SAFE_VALUE_DEPTH) return { | ||
| stringValue: TRUNCATED_VALUE | ||
| }; | ||
| if (value instanceof Date) { | ||
| const time = value.getTime(); | ||
| const iso = Number.isFinite(time) ? value.toISOString() : String(value); | ||
| return { | ||
| stringValue: 'string' == typeof iso ? sanitizeString(iso) : String(iso) | ||
| }; | ||
| } | ||
| state.ancestors.add(value); | ||
| try { | ||
| try { | ||
| const toJSON = value.toJSON; | ||
| if ('function' == typeof toJSON) return encodeAnyValue(toJSON.call(value), logger, state, depth + 1); | ||
| } catch {} | ||
| if (isArray(value)) return { | ||
| arrayValue: { | ||
| values: encodeArrayValues(value, logger, state, depth + 1) | ||
| } | ||
| }; | ||
| return { | ||
| kvlistValue: { | ||
| values: encodeKeyValueList(value, logger, state, depth + 1) | ||
| } | ||
| }; | ||
| } finally{ | ||
| state.ancestors.delete(value); | ||
| } | ||
| } | ||
| return { | ||
| stringValue: sanitizeString(String(value)) | ||
| }; | ||
| } | ||
| function toOtlpKeyValueList(attrs) { | ||
| function encodeArrayValues(values, logger, state, depth) { | ||
| const result = []; | ||
| for(const key in attrs){ | ||
| const value = attrs[key]; | ||
| if (!(isNull(value) || isUndefined(value))) result.push({ | ||
| key, | ||
| value: toOtlpAnyValue(value) | ||
| const itemCount = Math.min(values.length, MAX_JSON_SAFE_VALUE_ITEMS); | ||
| let index = 0; | ||
| for(; index < itemCount && state.remainingNodes > 0; index++)try { | ||
| const element = index in values ? values[index] : void 0; | ||
| if (isNullish(element)) continue; | ||
| result.push(encodeAnyValue(element, logger, state, depth)); | ||
| } catch { | ||
| result.push({ | ||
| stringValue: UNSERIALIZABLE_VALUE | ||
| }); | ||
| } | ||
| if (values.length > index) result.push({ | ||
| stringValue: TRUNCATED_VALUE | ||
| }); | ||
| return result; | ||
| } | ||
| function encodeKeyValueList(attrs, logger, state, depth) { | ||
| const result = []; | ||
| for(const key in attrs)if (propertyIsEnumerable.call(attrs, key)) { | ||
| if (result.length >= MAX_JSON_SAFE_VALUE_ITEMS || state.remainingNodes <= 0) { | ||
| logger?.debug('Attributes truncated: the value exceeds the OTLP encoder budget'); | ||
| break; | ||
| } | ||
| try { | ||
| const value = attrs[key]; | ||
| if (isNull(value) || isUndefined(value)) continue; | ||
| result.push({ | ||
| key: sanitizeString(key), | ||
| value: encodeAnyValue(value, logger, state, depth) | ||
| }); | ||
| } catch { | ||
| result.push({ | ||
| key: sanitizeString(key), | ||
| value: { | ||
| stringValue: UNSERIALIZABLE_VALUE | ||
| } | ||
| }); | ||
| } | ||
| } | ||
| return result; | ||
| } | ||
| function timestampToUnixNano() { | ||
| return String(Date.now()) + '000000'; | ||
| } | ||
| function buildOtlpLogRecord(options, sdkContext) { | ||
| function encodeBody(body) { | ||
| try { | ||
| return sanitizeString(String(body)); | ||
| } catch { | ||
| return UNSERIALIZABLE_VALUE; | ||
| } | ||
| } | ||
| function buildOtlpLogRecord(options, sdkContext, logger) { | ||
| const level = options.level || 'info'; | ||
@@ -100,5 +212,27 @@ const { text: severityText, number: severityNumber } = OTLP_SEVERITY_MAP[level] || DEFAULT_OTLP_SEVERITY; | ||
| const mergedAttributes = { | ||
| ...autoAttributes, | ||
| ...options.attributes || {} | ||
| ...autoAttributes | ||
| }; | ||
| const userAttributes = options.attributes; | ||
| if (userAttributes) { | ||
| let keys = []; | ||
| try { | ||
| keys = Object.keys(userAttributes); | ||
| } catch { | ||
| keys = []; | ||
| } | ||
| for (const key of keys){ | ||
| let value; | ||
| try { | ||
| value = userAttributes[key]; | ||
| } catch { | ||
| value = UNSERIALIZABLE_VALUE; | ||
| } | ||
| Object.defineProperty(mergedAttributes, key, { | ||
| value, | ||
| enumerable: true, | ||
| writable: true, | ||
| configurable: true | ||
| }); | ||
| } | ||
| } | ||
| const record = { | ||
@@ -110,5 +244,5 @@ timeUnixNano: now, | ||
| body: { | ||
| stringValue: options.body | ||
| stringValue: encodeBody(options.body) | ||
| }, | ||
| attributes: toOtlpKeyValueList(mergedAttributes) | ||
| attributes: toOtlpKeyValueList(mergedAttributes, logger) | ||
| }; | ||
@@ -115,0 +249,0 @@ if (options.trace_id) record.traceId = options.trace_id; |
@@ -271,3 +271,3 @@ "use strict"; | ||
| } | ||
| const attributes = (0, logs_utils_js_namespaceObject.toOtlpKeyValueList)(state.attributes ?? {}); | ||
| const attributes = (0, logs_utils_js_namespaceObject.toOtlpKeyValueList)(state.attributes ?? {}, this._logger); | ||
| const startNano = (0, external_metrics_utils_js_namespaceObject.msToUnixNano)(state.windowStartMs); | ||
@@ -274,0 +274,0 @@ if ('count' === state.type) { |
@@ -239,3 +239,3 @@ import { isArray, safeSetTimeout } from "../utils/index.mjs"; | ||
| } | ||
| const attributes = toOtlpKeyValueList(state.attributes ?? {}); | ||
| const attributes = toOtlpKeyValueList(state.attributes ?? {}, this._logger); | ||
| const startNano = msToUnixNano(state.windowStartMs); | ||
@@ -242,0 +242,0 @@ if ('count' === state.type) { |
@@ -5,3 +5,3 @@ import { FetchLike } from '../types'; | ||
| export * from './bucketed-rate-limiter'; | ||
| export * from './json-utils'; | ||
| export { toJsonSafeValue } from './json-utils'; | ||
| export * from './number-utils'; | ||
@@ -8,0 +8,0 @@ export * from './string-utils'; |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAA;AAEpC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,yBAAyB,CAAA;AACvC,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,cAAc,CAAA;AAC5B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,UAAU,CAAA;AACxB,cAAc,oBAAoB,CAAA;AAElC,eAAO,MAAM,aAAa,SAAS,CAAA;AAEnC,eAAO,MAAM,UAAU,QAAoE,CAAA;AAE3F,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAE3D;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,MAAM,GAAG,MAAM,CAE9E;AAED,wBAAgB,MAAM,CAAC,WAAW,EAAE,GAAG,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAI9D;AASD,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED,wBAAgB,YAAY,CAAC,CAAC,SAAS,MAAM,GAAG,SAAS,EAAE,GAAG,EAAE,CAAC,GAAG,CAAC,SAAS,MAAM,GAAG,MAAM,GAAG,SAAS,CAMxG;AAED,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAA;CACtC;AAED,wBAAsB,SAAS,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAAC,CAAC,CAAC,CAqB5F;AAED,wBAAgB,gBAAgB,IAAI,MAAM,CAEzC;AAED,wBAAgB,cAAc,IAAI,MAAM,CAEvC;AAED,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,OAAO,EAAE,MAAM,GAAG,GAAG,CAOnE;AAED,wBAAsB,eAAe,CAAC,CAAC,EACrC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EACnB,SAAS,EAAE,MAAM,EACjB,SAAS,CAAC,EAAE,MAAM,IAAI,GACrB,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAoBnB;AAGD,eAAO,MAAM,SAAS,GAAI,KAAK,GAAG,KAAG,GAAG,IAAI,OAAO,CAAC,GAAG,CAEtD,CAAA;AAED,wBAAgB,QAAQ,IAAI,SAAS,GAAG,SAAS,CAEhD;AAED,wBAAgB,UAAU,CAAC,CAAC,EAC1B,QAAQ,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,IAAI,GAAG,SAAS,CAAC,EAAE,GAC1C,OAAO,CAAC,CAAC;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,CAAC,EAAE,CAAC,CAStF"} | ||
| {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAA;AAEpC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,yBAAyB,CAAA;AAGvC,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAC9C,cAAc,gBAAgB,CAAA;AAC9B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,cAAc,CAAA;AAC5B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,UAAU,CAAA;AACxB,cAAc,oBAAoB,CAAA;AAElC,eAAO,MAAM,aAAa,SAAS,CAAA;AAEnC,eAAO,MAAM,UAAU,QAAoE,CAAA;AAE3F,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAE3D;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,MAAM,GAAG,MAAM,CAE9E;AAED,wBAAgB,MAAM,CAAC,WAAW,EAAE,GAAG,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAI9D;AASD,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED,wBAAgB,YAAY,CAAC,CAAC,SAAS,MAAM,GAAG,SAAS,EAAE,GAAG,EAAE,CAAC,GAAG,CAAC,SAAS,MAAM,GAAG,MAAM,GAAG,SAAS,CAMxG;AAED,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAA;CACtC;AAED,wBAAsB,SAAS,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAAC,CAAC,CAAC,CAqB5F;AAED,wBAAgB,gBAAgB,IAAI,MAAM,CAEzC;AAED,wBAAgB,cAAc,IAAI,MAAM,CAEvC;AAED,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,OAAO,EAAE,MAAM,GAAG,GAAG,CAOnE;AAED,wBAAsB,eAAe,CAAC,CAAC,EACrC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EACnB,SAAS,EAAE,MAAM,EACjB,SAAS,CAAC,EAAE,MAAM,IAAI,GACrB,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAoBnB;AAGD,eAAO,MAAM,SAAS,GAAI,KAAK,GAAG,KAAG,GAAG,IAAI,OAAO,CAAC,GAAG,CAEtD,CAAA;AAED,wBAAgB,QAAQ,IAAI,SAAS,GAAG,SAAS,CAEhD;AAED,wBAAgB,UAAU,CAAC,CAAC,EAC1B,QAAQ,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,IAAI,GAAG,SAAS,CAAC,EAAE,GAC1C,OAAO,CAAC,CAAC;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,CAAC,EAAE,CAAC,CAStF"} |
+14
-24
@@ -92,3 +92,4 @@ "use strict"; | ||
| safeSetTimeout: ()=>safeSetTimeout, | ||
| stripUrlHash: ()=>stripUrlHash | ||
| stripUrlHash: ()=>stripUrlHash, | ||
| toJsonSafeValue: ()=>_json_utils__WEBPACK_IMPORTED_MODULE_3__.toJsonSafeValue | ||
| }); | ||
@@ -100,2 +101,3 @@ var _bot_detection__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./bot-detection"); | ||
| "isValidUUID", | ||
| "toJsonSafeValue", | ||
| "currentISOTime", | ||
@@ -124,2 +126,3 @@ "safeSetTimeout", | ||
| "isValidUUID", | ||
| "toJsonSafeValue", | ||
| "currentISOTime", | ||
@@ -148,2 +151,3 @@ "safeSetTimeout", | ||
| "isValidUUID", | ||
| "toJsonSafeValue", | ||
| "currentISOTime", | ||
@@ -168,24 +172,2 @@ "safeSetTimeout", | ||
| var _json_utils__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__("./json-utils"); | ||
| var __WEBPACK_REEXPORT_OBJECT__ = {}; | ||
| for(var __WEBPACK_IMPORT_KEY__ in _json_utils__WEBPACK_IMPORTED_MODULE_3__)if ([ | ||
| "removeTrailingSlash", | ||
| "isValidUUID", | ||
| "currentISOTime", | ||
| "safeSetTimeout", | ||
| "UUID_REGEX", | ||
| "assert", | ||
| "allSettled", | ||
| "retriable", | ||
| "default", | ||
| "stripUrlHash", | ||
| "STRING_FORMAT", | ||
| "currentTimestamp", | ||
| "raceWithTimeout", | ||
| "getFetch", | ||
| "getEventUuid", | ||
| "isPromise" | ||
| ].indexOf(__WEBPACK_IMPORT_KEY__) < 0) __WEBPACK_REEXPORT_OBJECT__[__WEBPACK_IMPORT_KEY__] = (function(key) { | ||
| return _json_utils__WEBPACK_IMPORTED_MODULE_3__[key]; | ||
| }).bind(0, __WEBPACK_IMPORT_KEY__); | ||
| __webpack_require__.d(__webpack_exports__, __WEBPACK_REEXPORT_OBJECT__); | ||
| var _number_utils__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__("./number-utils"); | ||
@@ -196,2 +178,3 @@ var __WEBPACK_REEXPORT_OBJECT__ = {}; | ||
| "isValidUUID", | ||
| "toJsonSafeValue", | ||
| "currentISOTime", | ||
@@ -220,2 +203,3 @@ "safeSetTimeout", | ||
| "isValidUUID", | ||
| "toJsonSafeValue", | ||
| "currentISOTime", | ||
@@ -244,2 +228,3 @@ "safeSetTimeout", | ||
| "isValidUUID", | ||
| "toJsonSafeValue", | ||
| "currentISOTime", | ||
@@ -268,2 +253,3 @@ "safeSetTimeout", | ||
| "isValidUUID", | ||
| "toJsonSafeValue", | ||
| "currentISOTime", | ||
@@ -292,2 +278,3 @@ "safeSetTimeout", | ||
| "isValidUUID", | ||
| "toJsonSafeValue", | ||
| "currentISOTime", | ||
@@ -316,2 +303,3 @@ "safeSetTimeout", | ||
| "isValidUUID", | ||
| "toJsonSafeValue", | ||
| "currentISOTime", | ||
@@ -431,2 +419,3 @@ "safeSetTimeout", | ||
| exports.stripUrlHash = __webpack_exports__.stripUrlHash; | ||
| exports.toJsonSafeValue = __webpack_exports__.toJsonSafeValue; | ||
| for(var __webpack_i__ in __webpack_exports__)if (-1 === [ | ||
@@ -447,3 +436,4 @@ "STRING_FORMAT", | ||
| "safeSetTimeout", | ||
| "stripUrlHash" | ||
| "stripUrlHash", | ||
| "toJsonSafeValue" | ||
| ].indexOf(__webpack_i__)) exports[__webpack_i__] = __webpack_exports__[__webpack_i__]; | ||
@@ -450,0 +440,0 @@ Object.defineProperty(exports, '__esModule', { |
@@ -0,5 +1,5 @@ | ||
| import { toJsonSafeValue } from "./json-utils.mjs"; | ||
| export * from "./bot-detection.mjs"; | ||
| export * from "./browser-utils.mjs"; | ||
| export * from "./bucketed-rate-limiter.mjs"; | ||
| export * from "./json-utils.mjs"; | ||
| export * from "./number-utils.mjs"; | ||
@@ -91,2 +91,2 @@ export * from "./string-utils.mjs"; | ||
| } | ||
| export { STRING_FORMAT, UUID_REGEX, allSettled, assert, currentISOTime, currentTimestamp, getEventUuid, getFetch, isPromise, isValidUUID, raceWithTimeout, removeTrailingSlash, retriable, safeSetTimeout, stripUrlHash }; | ||
| export { STRING_FORMAT, UUID_REGEX, allSettled, assert, currentISOTime, currentTimestamp, getEventUuid, getFetch, isPromise, isValidUUID, raceWithTimeout, removeTrailingSlash, retriable, safeSetTimeout, stripUrlHash, toJsonSafeValue }; |
@@ -0,2 +1,17 @@ | ||
| /** Traversal budgets and markers, shared with the OTLP attribute encoder. Not | ||
| * public API — `utils/index.ts` re-exports `toJsonSafeValue` only. */ | ||
| export declare const MAX_JSON_SAFE_VALUE_DEPTH = 20; | ||
| export declare const MAX_JSON_SAFE_VALUE_ITEMS = 1000; | ||
| export declare const MAX_JSON_SAFE_VALUE_NODES = 10000; | ||
| export declare const CIRCULAR_VALUE = "[Circular]"; | ||
| export declare const TRUNCATED_VALUE = "[Truncated]"; | ||
| export declare const UNSERIALIZABLE_VALUE = "[Unserializable]"; | ||
| export declare const FUNCTION_VALUE = "[Function]"; | ||
| /** | ||
| * Replaces unpaired surrogates with U+FFFD. A lone surrogate survives | ||
| * `JSON.stringify` as a `\uD800`-style escape, which strict JSON parsers | ||
| * reject — for OTLP that means the whole request is refused. | ||
| */ | ||
| export declare function sanitizeString(value: string): string; | ||
| /** | ||
| * Converts an arbitrary value, sanitizing `toJSON` results when available. | ||
@@ -3,0 +18,0 @@ * The limits keep pathological values from causing unbounded recursion or traversal. |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"json-utils.d.ts","sourceRoot":"","sources":["../../src/utils/json-utils.ts"],"names":[],"mappings":"AAoCA;;;GAGG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CA2GvD"} | ||
| {"version":3,"file":"json-utils.d.ts","sourceRoot":"","sources":["../../src/utils/json-utils.ts"],"names":[],"mappings":"AAAA;sEACsE;AACtE,eAAO,MAAM,yBAAyB,KAAK,CAAA;AAC3C,eAAO,MAAM,yBAAyB,OAAQ,CAAA;AAC9C,eAAO,MAAM,yBAAyB,QAAS,CAAA;AAC/C,eAAO,MAAM,cAAc,eAAe,CAAA;AAC1C,eAAO,MAAM,eAAe,gBAAgB,CAAA;AAC5C,eAAO,MAAM,oBAAoB,qBAAqB,CAAA;AACtD,eAAO,MAAM,cAAc,eAAe,CAAA;AAW1C;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAiBpD;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CA2GvD"} |
@@ -27,2 +27,10 @@ "use strict"; | ||
| __webpack_require__.d(__webpack_exports__, { | ||
| CIRCULAR_VALUE: ()=>CIRCULAR_VALUE, | ||
| FUNCTION_VALUE: ()=>FUNCTION_VALUE, | ||
| MAX_JSON_SAFE_VALUE_DEPTH: ()=>MAX_JSON_SAFE_VALUE_DEPTH, | ||
| MAX_JSON_SAFE_VALUE_ITEMS: ()=>MAX_JSON_SAFE_VALUE_ITEMS, | ||
| MAX_JSON_SAFE_VALUE_NODES: ()=>MAX_JSON_SAFE_VALUE_NODES, | ||
| TRUNCATED_VALUE: ()=>TRUNCATED_VALUE, | ||
| UNSERIALIZABLE_VALUE: ()=>UNSERIALIZABLE_VALUE, | ||
| sanitizeString: ()=>sanitizeString, | ||
| toJsonSafeValue: ()=>toJsonSafeValue | ||
@@ -123,4 +131,20 @@ }); | ||
| } | ||
| exports.CIRCULAR_VALUE = __webpack_exports__.CIRCULAR_VALUE; | ||
| exports.FUNCTION_VALUE = __webpack_exports__.FUNCTION_VALUE; | ||
| exports.MAX_JSON_SAFE_VALUE_DEPTH = __webpack_exports__.MAX_JSON_SAFE_VALUE_DEPTH; | ||
| exports.MAX_JSON_SAFE_VALUE_ITEMS = __webpack_exports__.MAX_JSON_SAFE_VALUE_ITEMS; | ||
| exports.MAX_JSON_SAFE_VALUE_NODES = __webpack_exports__.MAX_JSON_SAFE_VALUE_NODES; | ||
| exports.TRUNCATED_VALUE = __webpack_exports__.TRUNCATED_VALUE; | ||
| exports.UNSERIALIZABLE_VALUE = __webpack_exports__.UNSERIALIZABLE_VALUE; | ||
| exports.sanitizeString = __webpack_exports__.sanitizeString; | ||
| exports.toJsonSafeValue = __webpack_exports__.toJsonSafeValue; | ||
| for(var __webpack_i__ in __webpack_exports__)if (-1 === [ | ||
| "CIRCULAR_VALUE", | ||
| "FUNCTION_VALUE", | ||
| "MAX_JSON_SAFE_VALUE_DEPTH", | ||
| "MAX_JSON_SAFE_VALUE_ITEMS", | ||
| "MAX_JSON_SAFE_VALUE_NODES", | ||
| "TRUNCATED_VALUE", | ||
| "UNSERIALIZABLE_VALUE", | ||
| "sanitizeString", | ||
| "toJsonSafeValue" | ||
@@ -127,0 +151,0 @@ ].indexOf(__webpack_i__)) exports[__webpack_i__] = __webpack_exports__[__webpack_i__]; |
@@ -94,2 +94,2 @@ const MAX_JSON_SAFE_VALUE_DEPTH = 20; | ||
| } | ||
| export { toJsonSafeValue }; | ||
| export { CIRCULAR_VALUE, FUNCTION_VALUE, MAX_JSON_SAFE_VALUE_DEPTH, MAX_JSON_SAFE_VALUE_ITEMS, MAX_JSON_SAFE_VALUE_NODES, TRUNCATED_VALUE, UNSERIALIZABLE_VALUE, sanitizeString, toJsonSafeValue }; |
+2
-2
| { | ||
| "name": "@posthog/core", | ||
| "version": "1.48.6", | ||
| "version": "1.48.7", | ||
| "bugs": { | ||
@@ -73,3 +73,3 @@ "url": "https://github.com/PostHog/posthog-js/issues" | ||
| "dependencies": { | ||
| "@posthog/types": "^1.405.0" | ||
| "@posthog/types": "^1.405.1" | ||
| }, | ||
@@ -76,0 +76,0 @@ "devDependencies": { |
@@ -122,3 +122,3 @@ import type { LogAttributeValue } from '@posthog/types' | ||
| // not corrupt recorded attributes). | ||
| const record = buildOtlpLogRecord(filtered, this._getContext()) | ||
| const record = buildOtlpLogRecord(filtered, this._getContext(), this._logger) | ||
| const entry: BufferedLogEntry = { record } | ||
@@ -125,0 +125,0 @@ |
@@ -1,2 +0,2 @@ | ||
| import type { CaptureLogOptions, LogSeverityLevel } from '@posthog/types' | ||
| import type { CaptureLogOptions, LogAttributeValue, LogSeverityLevel } from '@posthog/types' | ||
| import type { LogSdkContext } from './types' | ||
@@ -72,8 +72,34 @@ import { | ||
| it('converts integers', () => { | ||
| expect(toOtlpAnyValue(42)).toEqual({ intValue: 42 }) | ||
| expect(toOtlpAnyValue(0)).toEqual({ intValue: 0 }) | ||
| expect(toOtlpAnyValue(-7)).toEqual({ intValue: -7 }) | ||
| it('converts integers to decimal strings', () => { | ||
| expect(toOtlpAnyValue(42)).toEqual({ intValue: '42' }) | ||
| expect(toOtlpAnyValue(0)).toEqual({ intValue: '0' }) | ||
| expect(toOtlpAnyValue(-7)).toEqual({ intValue: '-7' }) | ||
| }) | ||
| // Spec: outside int64 it is a stringValue, never an intValue. | ||
| it('converts integers outside int64 to stringValue', () => { | ||
| expect(toOtlpAnyValue(2 ** 63)).toEqual({ stringValue: '9223372036854775808' }) | ||
| expect(toOtlpAnyValue(-(2 ** 64))).toEqual({ stringValue: '-18446744073709551616' }) | ||
| expect(toOtlpAnyValue(1e21)).toEqual({ stringValue: '1000000000000000000000' }) | ||
| }) | ||
| it('logs a debug line when an integer falls outside int64', () => { | ||
| const logger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), critical: jest.fn() } | ||
| toOtlpAnyValue(2 ** 63, logger as any) | ||
| expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('outside the int64 range')) | ||
| }) | ||
| it('keeps int64 min as intValue', () => { | ||
| // In range, but `String` renders it 192 below int64 min, so the decimal | ||
| // has to come from BigInt. | ||
| expect(toOtlpAnyValue(-(2 ** 63))).toEqual({ intValue: '-9223372036854775808' }) | ||
| }) | ||
| it('keeps large in-range integers exact', () => { | ||
| expect(toOtlpAnyValue(Number.MAX_SAFE_INTEGER)).toEqual({ intValue: '9007199254740991' }) | ||
| // The largest double below 2^63 — no double exists between the two. | ||
| expect(toOtlpAnyValue(9223372036854774784)).toEqual({ intValue: '9223372036854774784' }) | ||
| expect(toOtlpAnyValue(2 ** 62)).toEqual({ intValue: '4611686018427387904' }) | ||
| }) | ||
| it('converts floats to doubleValue', () => { | ||
@@ -112,3 +138,3 @@ expect(toOtlpAnyValue(3.14)).toEqual({ doubleValue: 3.14 }) | ||
| arrayValue: { | ||
| values: [{ intValue: 1 }, { stringValue: 'x' }, { boolValue: true }], | ||
| values: [{ intValue: '1' }, { stringValue: 'x' }, { boolValue: true }], | ||
| }, | ||
@@ -118,9 +144,288 @@ }) | ||
| it('JSON-stringifies plain objects', () => { | ||
| it('converts plain objects to kvlistValue', () => { | ||
| expect(toOtlpAnyValue({ a: 1, b: 'two' })).toEqual({ | ||
| stringValue: '{"a":1,"b":"two"}', | ||
| kvlistValue: { | ||
| values: [ | ||
| { key: 'a', value: { intValue: '1' } }, | ||
| { key: 'b', value: { stringValue: 'two' } }, | ||
| ], | ||
| }, | ||
| }) | ||
| }) | ||
| it('converts nested objects recursively', () => { | ||
| expect(toOtlpAnyValue({ outer: { inner: 1 } })).toEqual({ | ||
| kvlistValue: { | ||
| values: [ | ||
| { | ||
| key: 'outer', | ||
| value: { kvlistValue: { values: [{ key: 'inner', value: { intValue: '1' } }] } }, | ||
| }, | ||
| ], | ||
| }, | ||
| }) | ||
| }) | ||
| it('drops null and undefined keys inside objects', () => { | ||
| expect(toOtlpAnyValue({ kept: 1, gone: null, alsoGone: undefined })).toEqual({ | ||
| kvlistValue: { values: [{ key: 'kept', value: { intValue: '1' } }] }, | ||
| }) | ||
| }) | ||
| // Not in LogAttributeValue, but reachable at runtime from untyped callers. | ||
| it('encodes Dates as ISO strings', () => { | ||
| expect(toOtlpAnyValue(new Date('2026-08-20T10:00:00.000Z') as unknown as LogAttributeValue)).toEqual({ | ||
| stringValue: '2026-08-20T10:00:00.000Z', | ||
| }) | ||
| }) | ||
| it('marks circular references instead of recursing', () => { | ||
| const cyclic: Record<string, unknown> = { name: 'root' } | ||
| cyclic.self = cyclic | ||
| expect(toOtlpAnyValue(cyclic)).toEqual({ | ||
| kvlistValue: { | ||
| values: [ | ||
| { key: 'name', value: { stringValue: 'root' } }, | ||
| { key: 'self', value: { stringValue: '[Circular]' } }, | ||
| ], | ||
| }, | ||
| }) | ||
| }) | ||
| // An escaping error would surface in the caller's application code. | ||
| it('does not throw on an object nested past the depth cap', () => { | ||
| let deep: Record<string, unknown> = { end: true } | ||
| for (let i = 0; i < 25000; i++) { | ||
| deep = { next: deep } | ||
| } | ||
| expect(() => toOtlpAnyValue(deep)).not.toThrow() | ||
| }) | ||
| it('truncates at exactly 20 levels instead of recursing', () => { | ||
| let deep: Record<string, unknown> = { end: true } | ||
| for (let i = 0; i < 25; i++) { | ||
| deep = { next: deep } | ||
| } | ||
| const encoded = JSON.stringify(toOtlpAnyValue(deep)) | ||
| expect(encoded).toContain('[Truncated]') | ||
| expect(encoded.split('"next"').length - 1).toBe(20) | ||
| }) | ||
| it('marks a throwing getter without losing the rest of the object', () => { | ||
| const attrs = { | ||
| ok: 1, | ||
| get bad(): number { | ||
| throw new Error('getter blew up') | ||
| }, | ||
| } | ||
| expect(() => toOtlpKeyValueList(attrs)).not.toThrow() | ||
| expect(toOtlpKeyValueList(attrs)).toEqual([ | ||
| { key: 'ok', value: { intValue: '1' } }, | ||
| { key: 'bad', value: { stringValue: '[Unserializable]' } }, | ||
| ]) | ||
| }) | ||
| // for...in walks the prototype chain once own keys are exhausted. | ||
| it('ignores inherited enumerable properties', () => { | ||
| const inherited: Record<string, unknown> = Object.create({ fromPrototype: 'leaked' }) | ||
| inherited.own = 1 | ||
| expect(toOtlpAnyValue(inherited)).toEqual({ | ||
| kvlistValue: { values: [{ key: 'own', value: { intValue: '1' } }] }, | ||
| }) | ||
| }) | ||
| // `String(fn)` would put the function's source text on the wire. | ||
| it('marks function and symbol values instead of stringifying them', () => { | ||
| expect(toOtlpAnyValue({ handler: () => 1, retries: 2 } as unknown as LogAttributeValue)).toEqual({ | ||
| kvlistValue: { | ||
| values: [ | ||
| { key: 'handler', value: { stringValue: '[Function]' } }, | ||
| { key: 'retries', value: { intValue: '2' } }, | ||
| ], | ||
| }, | ||
| }) | ||
| expect(toOtlpAnyValue({ sym: Symbol('x') } as unknown as LogAttributeValue)).toEqual({ | ||
| kvlistValue: { values: [{ key: 'sym', value: { stringValue: 'Symbol(x)' } }] }, | ||
| }) | ||
| }) | ||
| // dayjs, Decimal, ORM documents. | ||
| it('honours toJSON', () => { | ||
| const wrapped = { toJSON: () => ({ amount: 5 }) } | ||
| expect(toOtlpAnyValue(wrapped as unknown as LogAttributeValue)).toEqual({ | ||
| kvlistValue: { values: [{ key: 'amount', value: { intValue: '5' } }] }, | ||
| }) | ||
| }) | ||
| it('falls back to the plain walk when toJSON throws', () => { | ||
| const wrapped = { | ||
| kept: 1, | ||
| toJSON: () => { | ||
| throw new Error('nope') | ||
| }, | ||
| } | ||
| expect(toOtlpAnyValue(wrapped as unknown as LogAttributeValue)).toEqual({ | ||
| kvlistValue: { | ||
| values: [ | ||
| { key: 'kept', value: { intValue: '1' } }, | ||
| { key: 'toJSON', value: { stringValue: '[Function]' } }, | ||
| ], | ||
| }, | ||
| }) | ||
| }) | ||
| // A toJSON returning its own object is a cycle like any other. | ||
| it('marks a cycle that runs through toJSON', () => { | ||
| const cyclic: Record<string, unknown> = {} | ||
| cyclic.toJSON = () => ({ inner: cyclic }) | ||
| expect(toOtlpAnyValue(cyclic)).toEqual({ | ||
| kvlistValue: { values: [{ key: 'inner', value: { stringValue: '[Circular]' } }] }, | ||
| }) | ||
| }) | ||
| // Both `null` and `{}` here are rejected for the whole request; iOS and | ||
| // Android drop them too. | ||
| it('drops holes and nullish elements from arrays', () => { | ||
| // eslint-disable-next-line no-sparse-arrays | ||
| expect(toOtlpAnyValue([1, , 3])).toEqual({ | ||
| arrayValue: { values: [{ intValue: '1' }, { intValue: '3' }] }, | ||
| }) | ||
| expect(toOtlpAnyValue([1, null, undefined, 3])).toEqual({ | ||
| arrayValue: { values: [{ intValue: '1' }, { intValue: '3' }] }, | ||
| }) | ||
| }) | ||
| it('stops encoding array items once the node budget is spent', () => { | ||
| const row: Record<string, number> = {} | ||
| for (let i = 0; i < 20; i++) { | ||
| row[`k${i}`] = i | ||
| } | ||
| const wide = Array.from({ length: 1000 }, () => ({ ...row })) | ||
| const values = toOtlpAnyValue(wide).arrayValue!.values | ||
| expect(values[values.length - 1]).toEqual({ stringValue: '[Truncated]' }) | ||
| // One marker, not one per unencodable item. | ||
| expect(values.filter((v) => v.stringValue === '[Truncated]')).toHaveLength(1) | ||
| }) | ||
| it('caps a shared object graph instead of expanding it', () => { | ||
| let graph: Record<string, unknown> = { leaf: true } | ||
| for (let i = 0; i < 20; i++) { | ||
| graph = { a: graph, b: graph } | ||
| } | ||
| const encoded = JSON.stringify(toOtlpAnyValue(graph)) | ||
| expect(encoded).toContain('[Truncated]') | ||
| expect(encoded.length).toBeLessThan(1_000_000) | ||
| }) | ||
| it('caps a very wide object without inventing an attribute key', () => { | ||
| const wide: Record<string, number> = {} | ||
| for (let i = 0; i < 5000; i++) { | ||
| wide[`k${i}`] = i | ||
| } | ||
| const logger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), critical: jest.fn() } | ||
| const values = toOtlpAnyValue(wide, logger as any).kvlistValue!.values | ||
| expect(values).toHaveLength(1000) | ||
| expect(values.every((v) => v.key.startsWith('k'))).toBe(true) | ||
| expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('truncated')) | ||
| }) | ||
| // Why the encoder does not delegate to toJsonSafeValue: that maps them to null. | ||
| it('keeps non-finite floats as strings inside nested objects', () => { | ||
| expect(toOtlpAnyValue({ nested: { ratio: NaN } })).toEqual({ | ||
| kvlistValue: { | ||
| values: [ | ||
| { | ||
| key: 'nested', | ||
| value: { kvlistValue: { values: [{ key: 'ratio', value: { stringValue: 'NaN' } }] } }, | ||
| }, | ||
| ], | ||
| }, | ||
| }) | ||
| }) | ||
| // A lone surrogate survives JSON.stringify as a \uD800 escape, which the | ||
| // server rejects for the whole request. | ||
| it('replaces unpaired surrogates in values and keys', () => { | ||
| expect(toOtlpAnyValue('ok\ud83d')).toEqual({ stringValue: 'ok\ufffd' }) | ||
| expect(toOtlpAnyValue({ nested: 'ok\ud83d' })).toEqual({ | ||
| kvlistValue: { values: [{ key: 'nested', value: { stringValue: 'ok\ufffd' } }] }, | ||
| }) | ||
| expect(toOtlpKeyValueList({ 'key\ud83d': 1 })).toEqual([{ key: 'key\ufffd', value: { intValue: '1' } }]) | ||
| }) | ||
| it('encodes empty containers with an explicit values array', () => { | ||
| expect(toOtlpAnyValue({})).toEqual({ kvlistValue: { values: [] } }) | ||
| expect(toOtlpAnyValue([])).toEqual({ arrayValue: { values: [] } }) | ||
| }) | ||
| it('keeps a Date whose toISOString is overridden out of the wire format', () => { | ||
| const broken = new Date('2026-08-20T10:00:00.000Z') | ||
| ;(broken as any).toISOString = () => ({}) | ||
| expect(typeof toOtlpAnyValue(broken as unknown as LogAttributeValue).stringValue).toBe('string') | ||
| }) | ||
| it('encodes sibling references to one object twice, not as circular', () => { | ||
| const shared = { id: 1 } | ||
| expect(toOtlpAnyValue({ a: shared, b: shared })).toEqual({ | ||
| kvlistValue: { | ||
| values: [ | ||
| { key: 'a', value: { kvlistValue: { values: [{ key: 'id', value: { intValue: '1' } }] } } }, | ||
| { key: 'b', value: { kvlistValue: { values: [{ key: 'id', value: { intValue: '1' } }] } } }, | ||
| ], | ||
| }, | ||
| }) | ||
| }) | ||
| }) | ||
| describe('buildOtlpLogRecord attribute reads', () => { | ||
| // Reading `options.attributes` happens before the encoder's per-key guard. | ||
| it('marks an attribute whose getter throws without dropping the record', () => { | ||
| const attributes = { | ||
| ok: 1, | ||
| get bad(): number { | ||
| throw new Error('disposed store') | ||
| }, | ||
| } | ||
| const record = buildOtlpLogRecord({ body: 'x', attributes }, {}) | ||
| expect(record.attributes).toContainEqual({ key: 'ok', value: { intValue: '1' } }) | ||
| expect(record.attributes).toContainEqual({ key: 'bad', value: { stringValue: '[Unserializable]' } }) | ||
| }) | ||
| // Plain assignment hits the prototype setter and the attribute vanishes. | ||
| it('keeps an attribute literally named __proto__', () => { | ||
| const attributes = JSON.parse('{"__proto__": {"a": 1}, "normal": "n"}') | ||
| const record = buildOtlpLogRecord({ body: 'x', attributes }, {}) | ||
| expect(record.attributes.map((a) => a.key)).toContain('__proto__') | ||
| }) | ||
| it('coerces a non-string body', () => { | ||
| // A non-string stringValue is refused for the whole request. | ||
| const record = buildOtlpLogRecord({ body: 12345 as unknown as string }, {}) | ||
| expect(record.body).toEqual({ stringValue: '12345' }) | ||
| }) | ||
| // `String()` throws on these, and buildOtlpLogRecord is called straight from | ||
| // captureLog — beforeSend can also hand back any body. | ||
| it('marks a body that cannot be coerced to a string', () => { | ||
| const throwing = { | ||
| toString() { | ||
| throw new Error('boom') | ||
| }, | ||
| } as unknown as string | ||
| expect(() => buildOtlpLogRecord({ body: throwing }, {})).not.toThrow() | ||
| expect(buildOtlpLogRecord({ body: throwing }, {}).body).toEqual({ stringValue: '[Unserializable]' }) | ||
| expect(buildOtlpLogRecord({ body: Object.create(null) }, {}).body).toEqual({ | ||
| stringValue: '[Unserializable]', | ||
| }) | ||
| }) | ||
| it('keeps the record when the attributes object itself cannot be read', () => { | ||
| const revocable = Proxy.revocable({ a: 1 }, {}) | ||
| revocable.revoke() | ||
| expect(buildOtlpLogRecord({ body: 'x', attributes: revocable.proxy }, {}).body).toEqual({ stringValue: 'x' }) | ||
| }) | ||
| }) | ||
| describe('toOtlpKeyValueList', () => { | ||
@@ -136,3 +441,3 @@ it('converts a record to key-value list', () => { | ||
| { key: 'name', value: { stringValue: 'test' } }, | ||
| { key: 'count', value: { intValue: 5 } }, | ||
| { key: 'count', value: { intValue: '5' } }, | ||
| { key: 'active', value: { boolValue: true } }, | ||
@@ -139,0 +444,0 @@ ]) |
+215
-27
@@ -12,4 +12,15 @@ import type { | ||
| } from '@posthog/types' | ||
| import type { Logger } from '../types' | ||
| import type { LogSdkContext, ResolvedPostHogLogsConfig } from './types' | ||
| import { isArray, isBoolean, isNull, isNullish, isUndefined } from '../utils' | ||
| import { | ||
| CIRCULAR_VALUE, | ||
| FUNCTION_VALUE, | ||
| MAX_JSON_SAFE_VALUE_DEPTH, | ||
| MAX_JSON_SAFE_VALUE_ITEMS, | ||
| MAX_JSON_SAFE_VALUE_NODES, | ||
| sanitizeString, | ||
| TRUNCATED_VALUE, | ||
| UNSERIALIZABLE_VALUE, | ||
| } from '../utils/json-utils' | ||
@@ -43,13 +54,51 @@ // ============================================================================ | ||
| export function toOtlpAnyValue(value: LogAttributeValue): OtlpAnyValue { | ||
| // 2^63 — one past int64 max. | ||
| const INT64_RANGE_LIMIT = 9223372036854775808 | ||
| const propertyIsEnumerable = Object.prototype.propertyIsEnumerable | ||
| interface EncodeState { | ||
| /** Containers on the current path, so a back-reference becomes a marker. */ | ||
| ancestors: WeakSet<object> | ||
| remainingNodes: number | ||
| } | ||
| function newState(): EncodeState { | ||
| return { ancestors: new WeakSet(), remainingNodes: MAX_JSON_SAFE_VALUE_NODES } | ||
| } | ||
| export function toOtlpAnyValue(value: LogAttributeValue, logger?: Logger): OtlpAnyValue { | ||
| try { | ||
| return encodeAnyValue(value, logger, newState(), 0) | ||
| } catch { | ||
| // Runs inside `captureLog` and the metrics flush: an error escaping here | ||
| // surfaces in the caller's own code. | ||
| return { stringValue: UNSERIALIZABLE_VALUE } | ||
| } | ||
| } | ||
| export function toOtlpKeyValueList(attrs: Record<string, LogAttributeValue>, logger?: Logger): OtlpKeyValue[] { | ||
| try { | ||
| return encodeKeyValueList(attrs, logger, newState(), 0) | ||
| } catch { | ||
| return [] | ||
| } | ||
| } | ||
| function encodeAnyValue( | ||
| value: LogAttributeValue, | ||
| logger: Logger | undefined, | ||
| state: EncodeState, | ||
| depth: number | ||
| ): OtlpAnyValue { | ||
| if (state.remainingNodes <= 0) { | ||
| return { stringValue: TRUNCATED_VALUE } | ||
| } | ||
| state.remainingNodes-- | ||
| if (isBoolean(value)) { | ||
| return { boolValue: value } | ||
| } | ||
| // NOTE: typeof check (not core's isNumber) so NaN is included. core's | ||
| // isNumber explicitly excludes NaN via the `x === x` guard, which would | ||
| // route NaN through the JSON.stringify branch below — JSON has no | ||
| // representation for non-finite floats and JSON.stringify turns them into | ||
| // `null`, losing the value server-side. proto3 JSON mapping (which OTLP/HTTP | ||
| // rides) requires the literal strings; we encode them as stringValue to keep | ||
| // the human-readable signal regardless of which downstream parser sees them. | ||
| // typeof, not core's isNumber, which excludes NaN — proto3 JSON distinguishes | ||
| // a non-finite float from an ordinary string. | ||
| if (typeof value === 'number') { | ||
@@ -60,3 +109,20 @@ if (!Number.isFinite(value)) { | ||
| if (Number.isInteger(value)) { | ||
| return { intValue: value } | ||
| if (Number.isSafeInteger(value)) { | ||
| return { intValue: String(value) } | ||
| } | ||
| // Past MAX_SAFE_INTEGER only BigInt gives the double's exact decimal: | ||
| // `String(-(2**63))` lands 192 below int64 min, outside the field it is | ||
| // about to be parsed into. Without BigInt the value rides as a string, | ||
| // which is never range-checked. | ||
| if (typeof BigInt === 'undefined') { | ||
| return { stringValue: String(value) } | ||
| } | ||
| const decimal = BigInt(value).toString() | ||
| if (value >= INT64_RANGE_LIMIT || value < -INT64_RANGE_LIMIT) { | ||
| // An out-of-range intValue 400s the whole logs request; on the metrics | ||
| // path it is swallowed server-side and the metric just disappears. | ||
| logger?.debug(`Attribute ${decimal} is outside the int64 range; encoding it as a string`) | ||
| return { stringValue: decimal } | ||
| } | ||
| return { intValue: decimal } | ||
| } | ||
@@ -66,24 +132,112 @@ return { doubleValue: value } | ||
| if (typeof value === 'string') { | ||
| return { stringValue: value } | ||
| return { stringValue: sanitizeString(value) } | ||
| } | ||
| if (isArray(value)) { | ||
| return { arrayValue: { values: value.map((v) => toOtlpAnyValue(v as LogAttributeValue)) } } | ||
| // `String(value)` would put a function's source text on the wire. | ||
| if (typeof value === 'function') { | ||
| return { stringValue: FUNCTION_VALUE } | ||
| } | ||
| // Objects fall back to JSON. OTLP supports kvlistValue but the encoder | ||
| // stays flat for simplicity. | ||
| try { | ||
| return { stringValue: JSON.stringify(value) } | ||
| } catch { | ||
| if (typeof value === 'symbol') { | ||
| return { stringValue: String(value) } | ||
| } | ||
| if (typeof value === 'object' && value !== null) { | ||
| if (state.ancestors.has(value)) { | ||
| return { stringValue: CIRCULAR_VALUE } | ||
| } | ||
| if (depth >= MAX_JSON_SAFE_VALUE_DEPTH) { | ||
| return { stringValue: TRUNCATED_VALUE } | ||
| } | ||
| if (value instanceof Date) { | ||
| const time = value.getTime() | ||
| const iso = Number.isFinite(time) ? value.toISOString() : String(value) | ||
| // An overridden toISOString can return a non-string, which the server | ||
| // refuses for the whole request. | ||
| return { stringValue: typeof iso === 'string' ? sanitizeString(iso) : String(iso) } | ||
| } | ||
| // Registered before the toJSON probe: a toJSON returning a structure that | ||
| // references its own object is a cycle like any other. | ||
| state.ancestors.add(value) | ||
| try { | ||
| // The representation a value defines for itself — dayjs, Decimal, an ORM | ||
| // document, and a cross-realm Date that fails the `instanceof` above. | ||
| try { | ||
| const toJSON = (value as { toJSON?: unknown }).toJSON | ||
| if (typeof toJSON === 'function') { | ||
| return encodeAnyValue(toJSON.call(value) as LogAttributeValue, logger, state, depth + 1) | ||
| } | ||
| } catch { | ||
| // A throwing toJSON falls through to the plain walk. | ||
| } | ||
| if (isArray(value)) { | ||
| return { arrayValue: { values: encodeArrayValues(value, logger, state, depth + 1) } } | ||
| } | ||
| return { | ||
| kvlistValue: { | ||
| values: encodeKeyValueList(value as Record<string, LogAttributeValue>, logger, state, depth + 1), | ||
| }, | ||
| } | ||
| } finally { | ||
| // Siblings that reference the same object are duplication, not a cycle. | ||
| state.ancestors.delete(value) | ||
| } | ||
| } | ||
| return { stringValue: sanitizeString(String(value)) } | ||
| } | ||
| export function toOtlpKeyValueList(attrs: Record<string, LogAttributeValue>): OtlpKeyValue[] { | ||
| function encodeArrayValues( | ||
| values: unknown[], | ||
| logger: Logger | undefined, | ||
| state: EncodeState, | ||
| depth: number | ||
| ): OtlpAnyValue[] { | ||
| const result: OtlpAnyValue[] = [] | ||
| const itemCount = Math.min(values.length, MAX_JSON_SAFE_VALUE_ITEMS) | ||
| let index = 0 | ||
| for (; index < itemCount && state.remainingNodes > 0; index++) { | ||
| try { | ||
| const element = index in values ? values[index] : undefined | ||
| // Dropped, as iOS and Android do: proto3 JSON has no null AnyValue, and | ||
| // both `null` and `{}` here are rejected for the whole request. | ||
| if (isNullish(element)) { | ||
| continue | ||
| } | ||
| result.push(encodeAnyValue(element as LogAttributeValue, logger, state, depth)) | ||
| } catch { | ||
| result.push({ stringValue: UNSERIALIZABLE_VALUE }) | ||
| } | ||
| } | ||
| if (values.length > index) { | ||
| result.push({ stringValue: TRUNCATED_VALUE }) | ||
| } | ||
| return result | ||
| } | ||
| function encodeKeyValueList( | ||
| attrs: Record<string, LogAttributeValue>, | ||
| logger: Logger | undefined, | ||
| state: EncodeState, | ||
| depth: number | ||
| ): OtlpKeyValue[] { | ||
| const result: OtlpKeyValue[] = [] | ||
| for (const key in attrs) { | ||
| const value = attrs[key] | ||
| if (isNull(value) || isUndefined(value)) { | ||
| // for...in walks the prototype chain once own keys are exhausted. Skipped | ||
| // rather than broken out of: a proxy can yield keys in any order. | ||
| if (!propertyIsEnumerable.call(attrs, key)) { | ||
| continue | ||
| } | ||
| result.push({ key, value: toOtlpAnyValue(value) }) | ||
| if (result.length >= MAX_JSON_SAFE_VALUE_ITEMS || state.remainingNodes <= 0) { | ||
| // Reported rather than written into the attributes: a synthetic key would | ||
| // land in the user's own namespace and could collide with a real one. | ||
| logger?.debug('Attributes truncated: the value exceeds the OTLP encoder budget') | ||
| break | ||
| } | ||
| try { | ||
| const value = attrs[key] | ||
| if (isNull(value) || isUndefined(value)) { | ||
| continue | ||
| } | ||
| result.push({ key: sanitizeString(key), value: encodeAnyValue(value, logger, state, depth) }) | ||
| } catch { | ||
| // A getter that throws costs its own key, not the whole record. | ||
| result.push({ key: sanitizeString(key), value: { stringValue: UNSERIALIZABLE_VALUE } }) | ||
| } | ||
| } | ||
@@ -118,3 +272,18 @@ return result | ||
| */ | ||
| export function buildOtlpLogRecord(options: CaptureLogOptions, sdkContext: LogSdkContext): OtlpLogRecord { | ||
| // `body` is only typed as a string. An untyped caller — or a `beforeSend` that | ||
| // rewrites it — can pass anything, and `String()` throws on a value whose | ||
| // `toString` does, or on a null-prototype object. | ||
| function encodeBody(body: string): string { | ||
| try { | ||
| return sanitizeString(String(body)) | ||
| } catch { | ||
| return UNSERIALIZABLE_VALUE | ||
| } | ||
| } | ||
| export function buildOtlpLogRecord( | ||
| options: CaptureLogOptions, | ||
| sdkContext: LogSdkContext, | ||
| logger?: Logger | ||
| ): OtlpLogRecord { | ||
| const level: LogSeverityLevel = options.level || 'info' | ||
@@ -154,5 +323,24 @@ const { text: severityText, number: severityNumber } = OTLP_SEVERITY_MAP[level] || DEFAULT_OTLP_SEVERITY | ||
| const mergedAttributes = { | ||
| ...autoAttributes, | ||
| ...(options.attributes || {}), | ||
| // Read key by key rather than spreading: a getter over a disposed store or a | ||
| // revoked proxy throws on the read itself, before the encoder's guards see it. | ||
| const mergedAttributes: Record<string, LogAttributeValue> = { ...autoAttributes } | ||
| const userAttributes = options.attributes | ||
| if (userAttributes) { | ||
| let keys: string[] = [] | ||
| try { | ||
| keys = Object.keys(userAttributes) | ||
| } catch { | ||
| keys = [] | ||
| } | ||
| for (const key of keys) { | ||
| let value: LogAttributeValue | ||
| try { | ||
| value = userAttributes[key] | ||
| } catch { | ||
| value = UNSERIALIZABLE_VALUE | ||
| } | ||
| // defineProperty, not assignment: `attributes['__proto__'] = v` hits the | ||
| // prototype setter and the attribute vanishes. | ||
| Object.defineProperty(mergedAttributes, key, { value, enumerable: true, writable: true, configurable: true }) | ||
| } | ||
| } | ||
@@ -165,4 +353,4 @@ | ||
| severityText, | ||
| body: { stringValue: options.body }, | ||
| attributes: toOtlpKeyValueList(mergedAttributes), | ||
| body: { stringValue: encodeBody(options.body) }, | ||
| attributes: toOtlpKeyValueList(mergedAttributes, logger), | ||
| } | ||
@@ -169,0 +357,0 @@ |
@@ -172,6 +172,20 @@ import type { Logger } from '../types' | ||
| expect(attrs).toContainEqual({ key: 'route', value: { stringValue: '/home' } }) | ||
| expect(attrs).toContainEqual({ key: 'retries', value: { intValue: 2 } }) | ||
| expect(attrs).toContainEqual({ key: 'retries', value: { intValue: '2' } }) | ||
| expect(attrs).toContainEqual({ key: 'cached', value: { boolValue: true } }) | ||
| }) | ||
| // The encoder is shared with logs, so the debug line it emits on the | ||
| // out-of-range path only reaches anyone if metrics passes its logger down. | ||
| it('encodes an out-of-int64 attribute as a string and reports it through the logger', async () => { | ||
| const metrics = createMetrics() | ||
| metrics.count('api_calls', 1, { attributes: { huge: 2 ** 63 } as any }) | ||
| await metrics.flush() | ||
| const attrs = sentMetrics()[0].sum!.dataPoints[0].attributes | ||
| expect(attrs).toContainEqual({ key: 'huge', value: { stringValue: '9223372036854775808' } }) | ||
| expect((logger.debug as jest.Mock).mock.calls.some((c) => String(c[0]).includes('outside the int64 range'))).toBe( | ||
| true | ||
| ) | ||
| }) | ||
| it('stamps delta data points with a window: startTimeUnixNano <= timeUnixNano, both nano strings', async () => { | ||
@@ -178,0 +192,0 @@ const metrics = createMetrics() |
@@ -377,3 +377,3 @@ import type { | ||
| const attributes = toOtlpKeyValueList(state.attributes ?? {}) | ||
| const attributes = toOtlpKeyValueList(state.attributes ?? {}, this._logger) | ||
| const startNano = msToUnixNano(state.windowStartMs) | ||
@@ -380,0 +380,0 @@ |
@@ -6,3 +6,5 @@ import { FetchLike } from '../types' | ||
| export * from './bucketed-rate-limiter' | ||
| export * from './json-utils' | ||
| // Named rather than `export *`: the budgets, markers and `sanitizeString` are | ||
| // shared with the OTLP encoder but are not public API. | ||
| export { toJsonSafeValue } from './json-utils' | ||
| export * from './number-utils' | ||
@@ -9,0 +11,0 @@ export * from './string-utils' |
@@ -1,8 +0,10 @@ | ||
| const MAX_JSON_SAFE_VALUE_DEPTH = 20 | ||
| const MAX_JSON_SAFE_VALUE_ITEMS = 1_000 | ||
| const MAX_JSON_SAFE_VALUE_NODES = 10_000 | ||
| const CIRCULAR_VALUE = '[Circular]' | ||
| const TRUNCATED_VALUE = '[Truncated]' | ||
| const UNSERIALIZABLE_VALUE = '[Unserializable]' | ||
| const FUNCTION_VALUE = '[Function]' | ||
| /** Traversal budgets and markers, shared with the OTLP attribute encoder. Not | ||
| * public API — `utils/index.ts` re-exports `toJsonSafeValue` only. */ | ||
| export const MAX_JSON_SAFE_VALUE_DEPTH = 20 | ||
| export const MAX_JSON_SAFE_VALUE_ITEMS = 1_000 | ||
| export const MAX_JSON_SAFE_VALUE_NODES = 10_000 | ||
| export const CIRCULAR_VALUE = '[Circular]' | ||
| export const TRUNCATED_VALUE = '[Truncated]' | ||
| export const UNSERIALIZABLE_VALUE = '[Unserializable]' | ||
| export const FUNCTION_VALUE = '[Function]' | ||
@@ -18,3 +20,8 @@ const dateGetTime = Date.prototype.getTime | ||
| function sanitizeString(value: string): string { | ||
| /** | ||
| * Replaces unpaired surrogates with U+FFFD. A lone surrogate survives | ||
| * `JSON.stringify` as a `\uD800`-style escape, which strict JSON parsers | ||
| * reject — for OTLP that means the whole request is refused. | ||
| */ | ||
| export function sanitizeString(value: string): string { | ||
| let output = '' | ||
@@ -21,0 +28,0 @@ for (let index = 0; index < value.length; index++) { |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1397699
2.37%32362
2.41%Updated