@logbrew/sdk 0.1.38 → 0.1.39

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 (90) hide show
  1. package/automatic-delivery.cjs +271 -0
  2. package/batch-sender.cjs +164 -0
  3. package/bounded-number.cjs +34 -0
  4. package/browser-breadcrumbs.cjs +346 -136
  5. package/capture-support.cjs +100 -0
  6. package/client-options.cjs +292 -0
  7. package/client-report.cjs +232 -0
  8. package/client.cjs +447 -0
  9. package/console-arguments.cjs +151 -0
  10. package/console-capture.cjs +194 -0
  11. package/core.cjs +30 -2810
  12. package/delivery-health.cjs +133 -0
  13. package/error-causes.cjs +130 -0
  14. package/error-details.cjs +124 -0
  15. package/error-issue.cjs +187 -0
  16. package/event-clone.cjs +55 -0
  17. package/event-metadata.cjs +40 -0
  18. package/event-pipeline.cjs +325 -0
  19. package/event-queue.cjs +306 -0
  20. package/event-store.cjs +237 -0
  21. package/event-validation.cjs +159 -0
  22. package/examples/agent-timeline.cjs +57 -46
  23. package/examples/agent-timeline.mjs +41 -35
  24. package/examples/index.mjs +122 -94
  25. package/examples/readme-example.cjs +36 -27
  26. package/examples/readme-example.mjs +21 -17
  27. package/examples/real-user-smoke.cjs +41 -32
  28. package/examples/real-user-smoke.mjs +26 -22
  29. package/exception-chain.cjs +205 -0
  30. package/index.d.ts +144 -1076
  31. package/index.js +1 -1
  32. package/issue-exception.cjs +13 -8
  33. package/metric-validation.cjs +95 -0
  34. package/network-milestone.cjs +155 -0
  35. package/null-value.cjs +4 -0
  36. package/opentelemetry-bridge.cjs +330 -0
  37. package/opentelemetry-context.cjs +245 -0
  38. package/opentelemetry-exceptions.cjs +69 -0
  39. package/opentelemetry-keys.cjs +183 -0
  40. package/opentelemetry-span.cjs +286 -0
  41. package/opentelemetry-summary.cjs +213 -0
  42. package/opentelemetry-time.cjs +131 -0
  43. package/opentelemetry.cjs +99 -1068
  44. package/package.json +55 -1
  45. package/pino-destination.cjs +108 -0
  46. package/pino-record.cjs +237 -0
  47. package/product-action.cjs +163 -0
  48. package/provided-breadcrumbs.cjs +117 -0
  49. package/random-hex.cjs +29 -0
  50. package/react-native.js +1 -1
  51. package/recording-transport.cjs +68 -0
  52. package/release-artifacts-build.cjs +263 -84
  53. package/release-artifacts-common.js +347 -66
  54. package/release-artifacts-debug-id.js +178 -0
  55. package/release-artifacts-inspect.js +213 -0
  56. package/release-artifacts-issue-event.js +99 -0
  57. package/release-artifacts-manifest-artifact.js +107 -0
  58. package/release-artifacts-manifest.js +77 -0
  59. package/release-artifacts-mappings.js +265 -0
  60. package/release-artifacts-prepare.js +297 -0
  61. package/release-artifacts-source-context.js +210 -0
  62. package/release-artifacts-symbolication.js +272 -421
  63. package/release-artifacts-upload-files.js +122 -0
  64. package/release-artifacts-upload-request.js +246 -0
  65. package/release-artifacts-upload.js +155 -300
  66. package/release-artifacts-urls.js +49 -0
  67. package/release-artifacts.js +189 -662
  68. package/retry-delay.cjs +32 -0
  69. package/span-validation.cjs +191 -0
  70. package/stored-events.cjs +243 -0
  71. package/types/client.d.ts +67 -0
  72. package/types/delivery.d.ts +125 -0
  73. package/types/issues.d.ts +249 -0
  74. package/types/logging.d.ts +175 -0
  75. package/types/opentelemetry.d.ts +205 -0
  76. package/types/product.d.ts +128 -0
  77. package/types/recording-transport.d.ts +15 -0
  78. package/types/sdk-error.d.ts +9 -0
  79. package/types/telemetry.d.ts +171 -0
  80. package/types/trace-context.d.ts +110 -0
  81. package/types/transport-error.d.ts +10 -0
  82. package/utf8.cjs +37 -0
  83. package/validation.cjs +49 -1
  84. package/vite-release-artifacts-config.cjs +120 -0
  85. package/vite-release-artifacts.cjs +199 -99
  86. package/vite-release-artifacts.d.cts +65 -40
  87. package/vite-release-artifacts.d.ts +65 -40
  88. package/vite-release-artifacts.js +2 -2
  89. package/winston-info.cjs +205 -0
  90. package/winston.cjs +189 -289
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logbrew/sdk",
3
- "version": "0.1.38",
3
+ "version": "0.1.39",
4
4
  "description": "Public LogBrew JavaScript SDK for building, validating, and flushing event batches.",
5
5
  "type": "module",
6
6
  "main": "./index.cjs",
@@ -45,9 +45,43 @@
45
45
  "index.cjs",
46
46
  "browser-breadcrumbs.cjs",
47
47
  "core.cjs",
48
+ "capture-support.cjs",
49
+ "client.cjs",
50
+ "automatic-delivery.cjs",
51
+ "batch-sender.cjs",
52
+ "bounded-number.cjs",
53
+ "client-options.cjs",
54
+ "client-report.cjs",
55
+ "delivery-health.cjs",
56
+ "event-pipeline.cjs",
57
+ "event-queue.cjs",
58
+ "event-store.cjs",
59
+ "provided-breadcrumbs.cjs",
60
+ "random-hex.cjs",
61
+ "retry-delay.cjs",
62
+ "stored-events.cjs",
63
+ "utf8.cjs",
64
+ "console-arguments.cjs",
65
+ "console-capture.cjs",
66
+ "error-causes.cjs",
67
+ "error-details.cjs",
68
+ "error-issue.cjs",
69
+ "event-clone.cjs",
70
+ "event-metadata.cjs",
71
+ "event-validation.cjs",
72
+ "exception-chain.cjs",
73
+ "metric-validation.cjs",
74
+ "network-milestone.cjs",
75
+ "null-value.cjs",
76
+ "pino-destination.cjs",
77
+ "pino-record.cjs",
78
+ "product-action.cjs",
79
+ "recording-transport.cjs",
80
+ "span-validation.cjs",
48
81
  "index.js",
49
82
  "index.d.ts",
50
83
  "index.d.cts",
84
+ "types",
51
85
  "issue-breadcrumbs.cjs",
52
86
  "issue-diagnostics.cjs",
53
87
  "issue-evidence.cjs",
@@ -58,6 +92,13 @@
58
92
  "issue-text.cjs",
59
93
  "log-context.cjs",
60
94
  "opentelemetry.cjs",
95
+ "opentelemetry-bridge.cjs",
96
+ "opentelemetry-context.cjs",
97
+ "opentelemetry-exceptions.cjs",
98
+ "opentelemetry-keys.cjs",
99
+ "opentelemetry-span.cjs",
100
+ "opentelemetry-summary.cjs",
101
+ "opentelemetry-time.cjs",
61
102
  "react-native.js",
62
103
  "react-native.d.ts",
63
104
  "sdk-error.cjs",
@@ -67,12 +108,25 @@
67
108
  "transport-error.cjs",
68
109
  "validation.cjs",
69
110
  "winston.cjs",
111
+ "winston-info.cjs",
70
112
  "release-artifacts-common.js",
71
113
  "release-artifacts-build.cjs",
72
114
  "release-artifacts.js",
73
115
  "release-artifacts-upload.js",
74
116
  "release-artifacts-symbolication.js",
117
+ "release-artifacts-debug-id.js",
118
+ "release-artifacts-inspect.js",
119
+ "release-artifacts-issue-event.js",
120
+ "release-artifacts-manifest.js",
121
+ "release-artifacts-manifest-artifact.js",
122
+ "release-artifacts-mappings.js",
123
+ "release-artifacts-prepare.js",
124
+ "release-artifacts-source-context.js",
125
+ "release-artifacts-upload-files.js",
126
+ "release-artifacts-upload-request.js",
127
+ "release-artifacts-urls.js",
75
128
  "vite-release-artifacts.cjs",
129
+ "vite-release-artifacts-config.cjs",
76
130
  "vite-release-artifacts.js",
77
131
  "vite-release-artifacts.d.ts",
78
132
  "vite-release-artifacts.d.cts",
@@ -0,0 +1,108 @@
1
+ const { callableOrUndefined, captureSettings, settledFlush } = require("./capture-support.cjs");
2
+ const { LogBrewClient } = require("./client.cjs");
3
+ const { traceFromProvider } = require("./log-context.cjs");
4
+ const { NULL_VALUE } = require("./null-value.cjs");
5
+ const { logAttributesFromPinoRecord, timestampFromPinoRecord } = require("./pino-record.cjs");
6
+ const { SdkError } = require("./sdk-error.cjs");
7
+ const { isObjectValue, isTruthy } = require("./validation.cjs");
8
+
9
+ /**
10
+ * @import { CaptureClient } from "./capture-support.cjs"
11
+ * @typedef {{
12
+ * write: (chunk: unknown) => boolean,
13
+ * flush: () => Promise<unknown>,
14
+ * end: () => Promise<unknown>
15
+ * }} PinoDestination
16
+ */
17
+
18
+ const NO_PENDING_EVENTS = 0;
19
+ const LINE_BREAK_PATTERN = /\r?\n/u;
20
+
21
+ class PinoDestinationState {
22
+ /**
23
+ * @param {Readonly<Record<string, unknown>>} config
24
+ * @param {CaptureClient} client
25
+ */
26
+ constructor(config, client) {
27
+ this.client = client;
28
+ this.settings = captureSettings(config, "flushOnWrite", "pino");
29
+ this.traceProvider = callableOrUndefined(config["traceProvider"]);
30
+ this.captured = 0;
31
+ /** @type {Promise<unknown>} */
32
+ this.pendingFlush = Promise.resolve(NULL_VALUE);
33
+ }
34
+
35
+ /**
36
+ * @param {string} line
37
+ * @returns {void}
38
+ */
39
+ writeLine(line) {
40
+ const { settings } = this;
41
+ try {
42
+ /** @type {unknown} */
43
+ const record = JSON.parse(line);
44
+ this.captured += 1;
45
+ this.client.log(
46
+ `${String(settings.eventIdPrefix)}_${this.captured}`,
47
+ timestampFromPinoRecord(record, settings.timestamp),
48
+ logAttributesFromPinoRecord(record, {
49
+ includeErrorStack: settings.includeErrorStack,
50
+ logger: settings.logger,
51
+ metadata: settings.metadata,
52
+ trace: traceFromProvider(this.traceProvider, settings.onError)
53
+ })
54
+ );
55
+ if (settings.flushOnEvent && isTruthy(settings.transport)) {
56
+ this.pendingFlush = settledFlush(this.client.flush(settings.transport), settings.onError);
57
+ }
58
+ } catch (error) {
59
+ settings.onError(error);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * @returns {void}
65
+ */
66
+ flushPending() {
67
+ const { settings } = this;
68
+ if (isTruthy(settings.transport) && this.client.pendingEvents() > NO_PENDING_EVENTS) {
69
+ this.pendingFlush = settledFlush(this.client.flush(settings.transport), settings.onError);
70
+ }
71
+ }
72
+ }
73
+
74
+ /**
75
+ * @param {unknown} config
76
+ * @returns {PinoDestination}
77
+ */
78
+ function createLogBrewPinoDestination(config) {
79
+ if (!isObjectValue(config)) {
80
+ throw new SdkError("validation_error", "Pino destination config must be an object");
81
+ }
82
+ const { client } = config;
83
+ if (!(client instanceof LogBrewClient)) {
84
+ throw new SdkError("validation_error", "Pino destination client must be a LogBrewClient");
85
+ }
86
+ const state = new PinoDestinationState(config, client);
87
+ return {
88
+ write(chunk) {
89
+ for (const line of String(chunk).split(LINE_BREAK_PATTERN)) {
90
+ if (line.trim() !== "") {
91
+ state.writeLine(line);
92
+ }
93
+ }
94
+ return true;
95
+ },
96
+
97
+ async flush() {
98
+ state.flushPending();
99
+ return state.pendingFlush;
100
+ },
101
+
102
+ async end() {
103
+ return this.flush();
104
+ }
105
+ };
106
+ }
107
+
108
+ module.exports = { createLogBrewPinoDestination };
@@ -0,0 +1,237 @@
1
+ const { compactMetadata, isMetadataValue, traceMetadataFromLogContext } = require("./log-context.cjs");
2
+ const { SdkError } = require("./sdk-error.cjs");
3
+ const { isObjectValue, isRecord, isTruthy, stringOrUndefined } = require("./validation.cjs");
4
+
5
+ /**
6
+ * @import { Severity } from "./index.cjs"
7
+ * @typedef {Readonly<{ includeErrorStack?: unknown, logger?: unknown, metadata?: unknown, trace?: unknown }>} PinoRecordOptions
8
+ * @typedef {{ message: string, level: Severity, logger?: unknown, metadata: Record<string, unknown> }} PinoLogAttributes
9
+ */
10
+
11
+ const CRITICAL_LEVEL = 60;
12
+ const ERROR_LEVEL = 50;
13
+ const WARNING_LEVEL = 40;
14
+ const TRACE_LEVEL = 10;
15
+ const DEBUG_LEVEL = 20;
16
+ const INFO_LEVEL = 30;
17
+ /** @type {ReadonlyMap<number, string>} */
18
+ const PINO_LEVEL_LABELS = new Map([
19
+ [TRACE_LEVEL, "trace"],
20
+ [DEBUG_LEVEL, "debug"],
21
+ [INFO_LEVEL, "info"],
22
+ [WARNING_LEVEL, "warn"],
23
+ [ERROR_LEVEL, "error"],
24
+ [CRITICAL_LEVEL, "fatal"]
25
+ ]);
26
+ /** @type {ReadonlyMap<string, Severity>} */
27
+ const PINO_LEVEL_NAMES = new Map([
28
+ ["trace", "info"],
29
+ ["debug", "info"],
30
+ ["warn", "warning"],
31
+ ["warning", "warning"],
32
+ ["error", "error"],
33
+ ["fatal", "critical"],
34
+ ["critical", "critical"]
35
+ ]);
36
+ const PINO_HOST_FIELD = ["host", "name"].join("");
37
+ const PINO_RESERVED_FIELDS = new Set(["level", "time", "timestamp", "msg", "message", "err", "error", "pid", PINO_HOST_FIELD, "v"]);
38
+ const PINO_SENSITIVE_CONTEXT_FIELDS = new Set([
39
+ "apikey",
40
+ "authorization",
41
+ "baggage",
42
+ "body",
43
+ "cookie",
44
+ "credentials",
45
+ "cwd",
46
+ "directory",
47
+ "dirname",
48
+ "errorstack",
49
+ "file",
50
+ "filename",
51
+ "filepath",
52
+ "headers",
53
+ "href",
54
+ "key",
55
+ "password",
56
+ "payload",
57
+ "proxyauthorization",
58
+ "query",
59
+ "querystring",
60
+ "requestbody",
61
+ "requestheaders",
62
+ "requesturl",
63
+ "responsebody",
64
+ "responseheaders",
65
+ "responseurl",
66
+ "search",
67
+ "secret",
68
+ "setcookie",
69
+ "stack",
70
+ "token",
71
+ "traceparent",
72
+ "tracestate",
73
+ "uri",
74
+ "url"
75
+ ]);
76
+ const PINO_SENSITIVE_FIELD_SUFFIXES = [
77
+ "accesskey",
78
+ "apikey",
79
+ "authorization",
80
+ "body",
81
+ "cookie",
82
+ "credentials",
83
+ "headers",
84
+ "password",
85
+ "payload",
86
+ "privatekey",
87
+ "query",
88
+ "secret",
89
+ "stack",
90
+ "token",
91
+ "url"
92
+ ];
93
+ const FIELD_SEPARATOR_PATTERN = /[^a-z0-9]+/gu;
94
+
95
+ /**
96
+ * @param {unknown} level
97
+ * @returns {Severity}
98
+ */
99
+ function logbrewLevelFromPinoLevel(level) {
100
+ if (typeof level === "number" && Number.isFinite(level)) {
101
+ if (level >= CRITICAL_LEVEL) {
102
+ return "critical";
103
+ }
104
+ if (level >= ERROR_LEVEL) {
105
+ return "error";
106
+ }
107
+ return level >= WARNING_LEVEL ? "warning" : "info";
108
+ }
109
+ return PINO_LEVEL_NAMES.get(String(level).toLowerCase()) ?? "info";
110
+ }
111
+
112
+ /**
113
+ * @param {unknown} level
114
+ * @returns {string}
115
+ */
116
+ function pinoLevelLabel(level) {
117
+ if (typeof level === "string" && level.trim() !== "") {
118
+ return level;
119
+ }
120
+ if (typeof level !== "number" || !Number.isFinite(level)) {
121
+ return "info";
122
+ }
123
+ return PINO_LEVEL_LABELS.get(level) ?? String(level);
124
+ }
125
+
126
+ /**
127
+ * @param {Readonly<Record<string, unknown>>} record
128
+ * @returns {string}
129
+ */
130
+ function pinoMessage(record) {
131
+ const { msg, message } = record;
132
+ const error = record["err"] ?? record["error"];
133
+ const errorMessage = isObjectValue(error) ? error["message"] : undefined;
134
+ return stringOrUndefined(msg) ?? stringOrUndefined(message) ?? stringOrUndefined(errorMessage) ?? "pino event";
135
+ }
136
+
137
+ /**
138
+ * @param {string} key
139
+ * @returns {boolean}
140
+ */
141
+ function isSensitivePinoContextField(key) {
142
+ const normalized = key.toLowerCase().replaceAll(FIELD_SEPARATOR_PATTERN, "");
143
+ return PINO_SENSITIVE_CONTEXT_FIELDS.has(normalized)
144
+ || PINO_SENSITIVE_FIELD_SUFFIXES.some((suffix) => normalized.endsWith(suffix));
145
+ }
146
+
147
+ /**
148
+ * @param {Readonly<Record<string, unknown>>} record
149
+ * @returns {Record<string, unknown>}
150
+ */
151
+ function pinoContextMetadata(record) {
152
+ /** @type {Record<string, unknown>} */
153
+ const metadata = {};
154
+ for (const [key, value] of Object.entries(record)) {
155
+ if (!PINO_RESERVED_FIELDS.has(key) && !isSensitivePinoContextField(key) && isMetadataValue(value)) {
156
+ metadata[`context.${key}`] = value;
157
+ }
158
+ }
159
+ return metadata;
160
+ }
161
+
162
+ /**
163
+ * @param {unknown} error
164
+ * @param {boolean} includeErrorStack
165
+ * @returns {Record<string, unknown>}
166
+ */
167
+ function pinoErrorMetadata(error, includeErrorStack) {
168
+ if (error instanceof Error) {
169
+ return {
170
+ errorName: isTruthy(error.name) ? error.name : "Error",
171
+ ...(isTruthy(error.message) ? { errorMessage: error.message } : {}),
172
+ ...(includeErrorStack && isTruthy(error.stack) ? { errorStack: error.stack } : {})
173
+ };
174
+ }
175
+ if (isObjectValue(error)) {
176
+ const name = stringOrUndefined(error["type"] ?? error["name"]);
177
+ const message = stringOrUndefined(error["message"]);
178
+ const stack = stringOrUndefined(error["stack"]);
179
+ return {
180
+ ...(name === undefined ? {} : { errorName: name }),
181
+ ...(message === undefined ? {} : { errorMessage: message }),
182
+ ...(includeErrorStack && stack !== undefined ? { errorStack: stack } : {})
183
+ };
184
+ }
185
+ return typeof error === "string" && error.trim() !== "" ? { errorMessage: error } : {};
186
+ }
187
+
188
+ /**
189
+ * @param {unknown} record
190
+ * @param {PinoRecordOptions} [options]
191
+ * @returns {PinoLogAttributes}
192
+ */
193
+ function logAttributesFromPinoRecord(record, options = {}) {
194
+ if (!isRecord(record)) {
195
+ throw new SdkError("validation_error", "Pino record must be an object");
196
+ }
197
+ const level = logbrewLevelFromPinoLevel(record["level"]);
198
+ const metadata = {
199
+ ...compactMetadata(options.metadata),
200
+ pinoLevel: pinoLevelLabel(record["level"]),
201
+ ...pinoContextMetadata(record),
202
+ ...traceMetadataFromLogContext(options.trace)
203
+ };
204
+ const pinoLevelNumber = record["level"];
205
+ const errorMetadata = pinoErrorMetadata(record["err"] ?? record["error"], options.includeErrorStack === true);
206
+ return {
207
+ message: pinoMessage(record),
208
+
209
+ level,
210
+ ...(isTruthy(options.logger) ? { logger: options.logger } : {}),
211
+
212
+ metadata: {
213
+ ...metadata,
214
+ ...(typeof pinoLevelNumber === "number" && Number.isFinite(pinoLevelNumber) ? { pinoLevelNumber } : {}),
215
+ ...errorMetadata
216
+ }
217
+ };
218
+ }
219
+
220
+ /**
221
+ * @param {unknown} record
222
+ * @param {() => unknown} fallbackTimestamp
223
+ * @returns {unknown}
224
+ */
225
+ function timestampFromPinoRecord(record, fallbackTimestamp) {
226
+ const value = isObjectValue(record) ? record["time"] ?? record["timestamp"] : undefined;
227
+ if (typeof value === "number" && Number.isFinite(value)) {
228
+ return new Date(value).toISOString();
229
+ }
230
+ if (typeof value === "string" && value.trim() !== "") {
231
+ const parsed = new Date(value);
232
+ return Number.isNaN(parsed.valueOf()) ? value : parsed.toISOString();
233
+ }
234
+ return fallbackTimestamp();
235
+ }
236
+
237
+ module.exports = { logAttributesFromPinoRecord, timestampFromPinoRecord };
@@ -0,0 +1,163 @@
1
+ const { ACTION_STATUSES } = require("./event-validation.cjs");
2
+ const { compactMetadata } = require("./log-context.cjs");
3
+ const { SdkError } = require("./sdk-error.cjs");
4
+ const { hasUnicodeControlCharacter, isRecord, requireAllowedValue, requireNonEmpty, stringOrUndefined } = require("./validation.cjs");
5
+
6
+ /**
7
+ * @import { ActionAttributes, MetadataValue, ProductAnalyticsKind } from "./index.cjs"
8
+ * @typedef {ActionAttributes["status"]} ActionStatus
9
+ * @typedef {Readonly<{ metadata?: unknown }>} TimelineOptions
10
+ * @typedef {{ name: string, status: ActionStatus, metadata: Record<string, MetadataValue> }} TimelineAttributes
11
+ * @typedef {{
12
+ * funnel?: unknown,
13
+ * metadata?: unknown,
14
+ * name: string,
15
+ * routeTemplate?: unknown,
16
+ * screen?: unknown,
17
+ * sessionId?: unknown,
18
+ * status: ActionStatus,
19
+ * step?: unknown,
20
+ * traceId?: unknown
21
+ * }} ProductActionDetails
22
+ */
23
+
24
+ /** @type {1} */
25
+ const PRODUCT_ANALYTICS_SCHEMA_VERSION = 1;
26
+ /** @type {readonly ProductAnalyticsKind[]} */
27
+ const PRODUCT_ANALYTICS_KINDS = Object.freeze(["page_view", "screen_view", "interaction"]);
28
+ const MAX_PRODUCT_ANALYTICS_SURFACE_LENGTH = 256;
29
+ const ROUTE_BASE_URL = "https://logbrew.example";
30
+ const ROUTE_LOCATION_PATTERN = /[?#]/u;
31
+
32
+ /**
33
+ * @param {string} trimmed
34
+ * @returns {string}
35
+ */
36
+ function routePathname(trimmed) {
37
+ try {
38
+ const { pathname } = new URL(trimmed, ROUTE_BASE_URL);
39
+ return pathname === "" ? "/" : pathname;
40
+ } catch {
41
+ const [path] = trimmed.split(ROUTE_LOCATION_PATTERN);
42
+ return path === undefined || path === "" ? "/" : path;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * @param {unknown} routeTemplate
48
+ * @returns {string | undefined}
49
+ */
50
+ function sanitizeRouteTemplate(routeTemplate) {
51
+ if (routeTemplate === undefined) {
52
+ return undefined;
53
+ }
54
+ if (typeof routeTemplate !== "string") {
55
+ throw new SdkError("validation_error", "routeTemplate must be a string");
56
+ }
57
+ const trimmed = routeTemplate.trim();
58
+ return trimmed === "" ? "" : routePathname(trimmed);
59
+ }
60
+
61
+ /**
62
+ * @param {unknown} surface
63
+ * @returns {string | undefined}
64
+ */
65
+ function boundedProductAnalyticsSurface(surface) {
66
+ if (typeof surface !== "string") {
67
+ return undefined;
68
+ }
69
+ const normalized = surface.trim();
70
+ if (
71
+ normalized === ""
72
+ || [...normalized].length > MAX_PRODUCT_ANALYTICS_SURFACE_LENGTH
73
+ || hasUnicodeControlCharacter(normalized)
74
+ ) {
75
+ return undefined;
76
+ }
77
+ return normalized;
78
+ }
79
+
80
+ /**
81
+ * @param {ProductAnalyticsKind} kind
82
+ * @param {unknown} surface
83
+ * @returns {Record<string, unknown>}
84
+ */
85
+ function productAnalyticsMetadata(kind, surface) {
86
+ return {
87
+ analyticsSchemaVersion: PRODUCT_ANALYTICS_SCHEMA_VERSION,
88
+
89
+ analyticsKind: kind,
90
+ analyticsSurface: boundedProductAnalyticsSurface(surface)
91
+ };
92
+ }
93
+
94
+ /**
95
+ * @param {unknown} action
96
+ * @returns {ProductActionDetails}
97
+ */
98
+ function productActionDetails(action) {
99
+ if (typeof action === "string") {
100
+ return { name: action, status: "success" };
101
+ }
102
+ if (!isRecord(action)) {
103
+ throw new SdkError("validation_error", "product action must be a string or object");
104
+ }
105
+ const name = requireNonEmpty("product action name", action["name"]);
106
+ const status = requireAllowedValue(
107
+ "product action status",
108
+ action["status"] === undefined ? "success" : action["status"],
109
+ ACTION_STATUSES
110
+ );
111
+ return {
112
+ funnel: action["funnel"],
113
+ metadata: action["metadata"],
114
+ name,
115
+ routeTemplate: action["routeTemplate"],
116
+ screen: action["screen"],
117
+ sessionId: action["sessionId"],
118
+ status,
119
+ step: action["step"],
120
+ traceId: action["traceId"]
121
+ };
122
+ }
123
+
124
+ /**
125
+ * @param {unknown} action
126
+ * @param {TimelineOptions} [options]
127
+ * @returns {TimelineAttributes}
128
+ */
129
+ function createProductActionAttributes(action, options = {}) {
130
+ const details = productActionDetails(action);
131
+ const routeTemplate = sanitizeRouteTemplate(details.routeTemplate);
132
+ const surface = routeTemplate === undefined || routeTemplate === "" ? details.screen : routeTemplate;
133
+ return {
134
+ name: details.name,
135
+
136
+ status: details.status,
137
+
138
+ metadata: compactMetadata({
139
+ source: "product.action",
140
+ ...compactMetadata(options.metadata),
141
+ ...compactMetadata(details.metadata),
142
+ routeTemplate,
143
+
144
+ sessionId: stringOrUndefined(details.sessionId),
145
+
146
+ traceId: stringOrUndefined(details.traceId),
147
+
148
+ screen: stringOrUndefined(details.screen),
149
+
150
+ funnel: stringOrUndefined(details.funnel),
151
+
152
+ step: stringOrUndefined(details.step),
153
+ ...productAnalyticsMetadata("interaction", surface)
154
+ })
155
+ };
156
+ }
157
+
158
+ module.exports = {
159
+ PRODUCT_ANALYTICS_KINDS,
160
+ PRODUCT_ANALYTICS_SCHEMA_VERSION,
161
+ createProductActionAttributes,
162
+ sanitizeRouteTemplate
163
+ };