@safaricom-mxl/log 0.0.3
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.
- package/README.md +1040 -0
- package/dist/_http-DmaJ426Z.mjs +76 -0
- package/dist/_http-DmaJ426Z.mjs.map +1 -0
- package/dist/_severity-D_IU9-90.mjs +17 -0
- package/dist/_severity-D_IU9-90.mjs.map +1 -0
- package/dist/adapters/axiom.d.mts +64 -0
- package/dist/adapters/axiom.d.mts.map +1 -0
- package/dist/adapters/axiom.mjs +100 -0
- package/dist/adapters/axiom.mjs.map +1 -0
- package/dist/adapters/better-stack.d.mts +63 -0
- package/dist/adapters/better-stack.d.mts.map +1 -0
- package/dist/adapters/better-stack.mjs +98 -0
- package/dist/adapters/better-stack.mjs.map +1 -0
- package/dist/adapters/otlp.d.mts +85 -0
- package/dist/adapters/otlp.d.mts.map +1 -0
- package/dist/adapters/otlp.mjs +196 -0
- package/dist/adapters/otlp.mjs.map +1 -0
- package/dist/adapters/posthog.d.mts +107 -0
- package/dist/adapters/posthog.d.mts.map +1 -0
- package/dist/adapters/posthog.mjs +166 -0
- package/dist/adapters/posthog.mjs.map +1 -0
- package/dist/adapters/sentry.d.mts +80 -0
- package/dist/adapters/sentry.d.mts.map +1 -0
- package/dist/adapters/sentry.mjs +221 -0
- package/dist/adapters/sentry.mjs.map +1 -0
- package/dist/browser.d.mts +63 -0
- package/dist/browser.d.mts.map +1 -0
- package/dist/browser.mjs +95 -0
- package/dist/browser.mjs.map +1 -0
- package/dist/enrichers.d.mts +74 -0
- package/dist/enrichers.d.mts.map +1 -0
- package/dist/enrichers.mjs +172 -0
- package/dist/enrichers.mjs.map +1 -0
- package/dist/error.d.mts +65 -0
- package/dist/error.d.mts.map +1 -0
- package/dist/error.mjs +112 -0
- package/dist/error.mjs.map +1 -0
- package/dist/index.d.mts +6 -0
- package/dist/index.mjs +6 -0
- package/dist/logger.d.mts +46 -0
- package/dist/logger.d.mts.map +1 -0
- package/dist/logger.mjs +287 -0
- package/dist/logger.mjs.map +1 -0
- package/dist/next/client.d.mts +55 -0
- package/dist/next/client.d.mts.map +1 -0
- package/dist/next/client.mjs +44 -0
- package/dist/next/client.mjs.map +1 -0
- package/dist/next/index.d.mts +169 -0
- package/dist/next/index.d.mts.map +1 -0
- package/dist/next/index.mjs +280 -0
- package/dist/next/index.mjs.map +1 -0
- package/dist/nitro/errorHandler.d.mts +15 -0
- package/dist/nitro/errorHandler.d.mts.map +1 -0
- package/dist/nitro/errorHandler.mjs +41 -0
- package/dist/nitro/errorHandler.mjs.map +1 -0
- package/dist/nitro/module.d.mts +11 -0
- package/dist/nitro/module.d.mts.map +1 -0
- package/dist/nitro/module.mjs +23 -0
- package/dist/nitro/module.mjs.map +1 -0
- package/dist/nitro/plugin.d.mts +7 -0
- package/dist/nitro/plugin.d.mts.map +1 -0
- package/dist/nitro/plugin.mjs +145 -0
- package/dist/nitro/plugin.mjs.map +1 -0
- package/dist/nitro/v3/errorHandler.d.mts +24 -0
- package/dist/nitro/v3/errorHandler.d.mts.map +1 -0
- package/dist/nitro/v3/errorHandler.mjs +36 -0
- package/dist/nitro/v3/errorHandler.mjs.map +1 -0
- package/dist/nitro/v3/index.d.mts +5 -0
- package/dist/nitro/v3/index.mjs +5 -0
- package/dist/nitro/v3/middleware.d.mts +25 -0
- package/dist/nitro/v3/middleware.d.mts.map +1 -0
- package/dist/nitro/v3/middleware.mjs +45 -0
- package/dist/nitro/v3/middleware.mjs.map +1 -0
- package/dist/nitro/v3/module.d.mts +10 -0
- package/dist/nitro/v3/module.d.mts.map +1 -0
- package/dist/nitro/v3/module.mjs +22 -0
- package/dist/nitro/v3/module.mjs.map +1 -0
- package/dist/nitro/v3/plugin.d.mts +14 -0
- package/dist/nitro/v3/plugin.d.mts.map +1 -0
- package/dist/nitro/v3/plugin.mjs +162 -0
- package/dist/nitro/v3/plugin.mjs.map +1 -0
- package/dist/nitro/v3/useLogger.d.mts +24 -0
- package/dist/nitro/v3/useLogger.d.mts.map +1 -0
- package/dist/nitro/v3/useLogger.mjs +27 -0
- package/dist/nitro/v3/useLogger.mjs.map +1 -0
- package/dist/nitro-CrFBjY1Y.d.mts +42 -0
- package/dist/nitro-CrFBjY1Y.d.mts.map +1 -0
- package/dist/nitro-Dsv6dSzv.mjs +39 -0
- package/dist/nitro-Dsv6dSzv.mjs.map +1 -0
- package/dist/nuxt/module.d.mts +164 -0
- package/dist/nuxt/module.d.mts.map +1 -0
- package/dist/nuxt/module.mjs +84 -0
- package/dist/nuxt/module.mjs.map +1 -0
- package/dist/pipeline.d.mts +46 -0
- package/dist/pipeline.d.mts.map +1 -0
- package/dist/pipeline.mjs +122 -0
- package/dist/pipeline.mjs.map +1 -0
- package/dist/routes-BNbrnm14.mjs +39 -0
- package/dist/routes-BNbrnm14.mjs.map +1 -0
- package/dist/runtime/client/log.d.mts +15 -0
- package/dist/runtime/client/log.d.mts.map +1 -0
- package/dist/runtime/client/log.mjs +92 -0
- package/dist/runtime/client/log.mjs.map +1 -0
- package/dist/runtime/client/plugin.d.mts +5 -0
- package/dist/runtime/client/plugin.d.mts.map +1 -0
- package/dist/runtime/client/plugin.mjs +17 -0
- package/dist/runtime/client/plugin.mjs.map +1 -0
- package/dist/runtime/server/routes/_mxllog/ingest.post.d.mts +7 -0
- package/dist/runtime/server/routes/_mxllog/ingest.post.d.mts.map +1 -0
- package/dist/runtime/server/routes/_mxllog/ingest.post.mjs +123 -0
- package/dist/runtime/server/routes/_mxllog/ingest.post.mjs.map +1 -0
- package/dist/runtime/server/useLogger.d.mts +39 -0
- package/dist/runtime/server/useLogger.d.mts.map +1 -0
- package/dist/runtime/server/useLogger.mjs +43 -0
- package/dist/runtime/server/useLogger.mjs.map +1 -0
- package/dist/runtime/utils/parseError.d.mts +7 -0
- package/dist/runtime/utils/parseError.d.mts.map +1 -0
- package/dist/runtime/utils/parseError.mjs +29 -0
- package/dist/runtime/utils/parseError.mjs.map +1 -0
- package/dist/types.d.mts +496 -0
- package/dist/types.d.mts.map +1 -0
- package/dist/types.mjs +1 -0
- package/dist/utils.d.mts +34 -0
- package/dist/utils.d.mts.map +1 -0
- package/dist/utils.mjs +78 -0
- package/dist/utils.mjs.map +1 -0
- package/dist/workers.d.mts +46 -0
- package/dist/workers.d.mts.map +1 -0
- package/dist/workers.mjs +81 -0
- package/dist/workers.mjs.map +1 -0
- package/package.json +195 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { n as defineDrain, r as resolveAdapterConfig, t as httpPost } from "../_http-DmaJ426Z.mjs";
|
|
2
|
+
import { t as OTEL_SEVERITY_NUMBER } from "../_severity-D_IU9-90.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/adapters/sentry.ts
|
|
5
|
+
const SENTRY_FIELDS = [
|
|
6
|
+
{
|
|
7
|
+
key: "dsn",
|
|
8
|
+
env: ["NUXT_SENTRY_DSN", "SENTRY_DSN"]
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
key: "environment",
|
|
12
|
+
env: ["NUXT_SENTRY_ENVIRONMENT", "SENTRY_ENVIRONMENT"]
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
key: "release",
|
|
16
|
+
env: ["NUXT_SENTRY_RELEASE", "SENTRY_RELEASE"]
|
|
17
|
+
},
|
|
18
|
+
{ key: "tags" },
|
|
19
|
+
{ key: "timeout" }
|
|
20
|
+
];
|
|
21
|
+
function parseSentryDsn(dsn) {
|
|
22
|
+
const url = new URL(dsn);
|
|
23
|
+
const publicKey = url.username;
|
|
24
|
+
if (!publicKey) throw new Error("Invalid Sentry DSN: missing public key");
|
|
25
|
+
const secretKey = url.password || void 0;
|
|
26
|
+
const pathParts = url.pathname.split("/").filter(Boolean);
|
|
27
|
+
const projectId = pathParts.pop();
|
|
28
|
+
if (!projectId) throw new Error("Invalid Sentry DSN: missing project ID");
|
|
29
|
+
const basePath = pathParts.length > 0 ? `/${pathParts.join("/")}` : "";
|
|
30
|
+
return {
|
|
31
|
+
publicKey,
|
|
32
|
+
secretKey,
|
|
33
|
+
projectId,
|
|
34
|
+
origin: `${url.protocol}//${url.host}`,
|
|
35
|
+
basePath
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function getSentryEnvelopeUrl(dsn) {
|
|
39
|
+
const { publicKey, secretKey, projectId, origin, basePath } = parseSentryDsn(dsn);
|
|
40
|
+
const url = `${origin}${basePath}/api/${projectId}/envelope/`;
|
|
41
|
+
let authHeader = `Sentry sentry_version=7, sentry_key=${publicKey}, sentry_client=mxllog`;
|
|
42
|
+
if (secretKey) authHeader += `, sentry_secret=${secretKey}`;
|
|
43
|
+
return {
|
|
44
|
+
url,
|
|
45
|
+
authHeader
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function createTraceId() {
|
|
49
|
+
if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID().replace(/-/g, "");
|
|
50
|
+
return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join("");
|
|
51
|
+
}
|
|
52
|
+
function getFirstStringValue(event, keys) {
|
|
53
|
+
for (const key of keys) {
|
|
54
|
+
const value = event[key];
|
|
55
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function toAttributeValue(value) {
|
|
59
|
+
if (value === null || value === void 0) return;
|
|
60
|
+
if (typeof value === "string") return {
|
|
61
|
+
value,
|
|
62
|
+
type: "string"
|
|
63
|
+
};
|
|
64
|
+
if (typeof value === "boolean") return {
|
|
65
|
+
value,
|
|
66
|
+
type: "boolean"
|
|
67
|
+
};
|
|
68
|
+
if (typeof value === "number") {
|
|
69
|
+
if (Number.isInteger(value)) return {
|
|
70
|
+
value,
|
|
71
|
+
type: "integer"
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
value,
|
|
75
|
+
type: "double"
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
value: JSON.stringify(value),
|
|
80
|
+
type: "string"
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function toSentryLog(event, config) {
|
|
84
|
+
const { timestamp, level, service, environment, version, ...rest } = event;
|
|
85
|
+
const body = getFirstStringValue(event, [
|
|
86
|
+
"message",
|
|
87
|
+
"action",
|
|
88
|
+
"path"
|
|
89
|
+
]) ?? "@safaricom-mxl/log wide event";
|
|
90
|
+
const traceId = typeof event.traceId === "string" && event.traceId.length > 0 ? event.traceId : createTraceId();
|
|
91
|
+
const attributes = {};
|
|
92
|
+
const env = config.environment ?? environment;
|
|
93
|
+
if (env) attributes["sentry.environment"] = {
|
|
94
|
+
value: env,
|
|
95
|
+
type: "string"
|
|
96
|
+
};
|
|
97
|
+
const rel = config.release ?? version;
|
|
98
|
+
if (typeof rel === "string" && rel.length > 0) attributes["sentry.release"] = {
|
|
99
|
+
value: rel,
|
|
100
|
+
type: "string"
|
|
101
|
+
};
|
|
102
|
+
attributes["service"] = {
|
|
103
|
+
value: service,
|
|
104
|
+
type: "string"
|
|
105
|
+
};
|
|
106
|
+
if (config.tags) for (const [key, value] of Object.entries(config.tags)) attributes[key] = {
|
|
107
|
+
value,
|
|
108
|
+
type: "string"
|
|
109
|
+
};
|
|
110
|
+
for (const [key, value] of Object.entries(rest)) {
|
|
111
|
+
if (key === "traceId" || key === "spanId") continue;
|
|
112
|
+
if (value === void 0 || value === null) continue;
|
|
113
|
+
const attr = toAttributeValue(value);
|
|
114
|
+
if (attr) attributes[key] = attr;
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
timestamp: new Date(timestamp).getTime() / 1e3,
|
|
118
|
+
trace_id: traceId,
|
|
119
|
+
level,
|
|
120
|
+
body,
|
|
121
|
+
severity_number: OTEL_SEVERITY_NUMBER[level] ?? 9,
|
|
122
|
+
attributes
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Build the Sentry Envelope body for a list of logs.
|
|
127
|
+
*
|
|
128
|
+
* Envelope format (line-delimited):
|
|
129
|
+
* - Line 1: Envelope headers (dsn, sent_at)
|
|
130
|
+
* - Line 2: Item header (type: log, item_count, content_type)
|
|
131
|
+
* - Line 3: Item payload ({"items": [...]})
|
|
132
|
+
*/
|
|
133
|
+
function buildEnvelopeBody(logs, dsn) {
|
|
134
|
+
return `${JSON.stringify({
|
|
135
|
+
dsn,
|
|
136
|
+
sent_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
137
|
+
})}\n${JSON.stringify({
|
|
138
|
+
type: "log",
|
|
139
|
+
item_count: logs.length,
|
|
140
|
+
content_type: "application/vnd.sentry.items.log+json"
|
|
141
|
+
})}\n${JSON.stringify({ items: logs })}\n`;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Create a drain function for sending logs to Sentry.
|
|
145
|
+
*
|
|
146
|
+
* Sends wide events as Sentry Structured Logs, visible in Explore > Logs
|
|
147
|
+
* in the Sentry dashboard.
|
|
148
|
+
*
|
|
149
|
+
* Configuration priority (highest to lowest):
|
|
150
|
+
* 1. Overrides passed to createSentryDrain()
|
|
151
|
+
* 2. runtimeConfig.mxllog.sentry
|
|
152
|
+
* 3. runtimeConfig.sentry
|
|
153
|
+
* 4. Environment variables: NUXT_SENTRY_*, SENTRY_*
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* ```ts
|
|
157
|
+
* // Zero config - just set NUXT_SENTRY_DSN env var
|
|
158
|
+
* nitroApp.hooks.hook('@safaricom-mxl/log:drain', createSentryDrain())
|
|
159
|
+
*
|
|
160
|
+
* // With overrides
|
|
161
|
+
* nitroApp.hooks.hook('@safaricom-mxl/log:drain', createSentryDrain({
|
|
162
|
+
* dsn: 'https://public@o0.ingest.sentry.io/123',
|
|
163
|
+
* }))
|
|
164
|
+
* ```
|
|
165
|
+
*/
|
|
166
|
+
function createSentryDrain(overrides) {
|
|
167
|
+
return defineDrain({
|
|
168
|
+
name: "sentry",
|
|
169
|
+
resolve: () => {
|
|
170
|
+
const config = resolveAdapterConfig("sentry", SENTRY_FIELDS, overrides);
|
|
171
|
+
if (!config.dsn) {
|
|
172
|
+
console.error("[mxllog/sentry] Missing DSN. Set NUXT_SENTRY_DSN/SENTRY_DSN env var or pass to createSentryDrain()");
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
return config;
|
|
176
|
+
},
|
|
177
|
+
send: sendBatchToSentry
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Send a single event to Sentry as a structured log.
|
|
182
|
+
*
|
|
183
|
+
* @example
|
|
184
|
+
* ```ts
|
|
185
|
+
* await sendToSentry(event, {
|
|
186
|
+
* dsn: process.env.SENTRY_DSN!,
|
|
187
|
+
* })
|
|
188
|
+
* ```
|
|
189
|
+
*/
|
|
190
|
+
async function sendToSentry(event, config) {
|
|
191
|
+
await sendBatchToSentry([event], config);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Send a batch of events to Sentry as structured logs via the Envelope endpoint.
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* ```ts
|
|
198
|
+
* await sendBatchToSentry(events, {
|
|
199
|
+
* dsn: process.env.SENTRY_DSN!,
|
|
200
|
+
* })
|
|
201
|
+
* ```
|
|
202
|
+
*/
|
|
203
|
+
async function sendBatchToSentry(events, config) {
|
|
204
|
+
if (events.length === 0) return;
|
|
205
|
+
const { url, authHeader } = getSentryEnvelopeUrl(config.dsn);
|
|
206
|
+
const body = buildEnvelopeBody(events.map((event) => toSentryLog(event, config)), config.dsn);
|
|
207
|
+
await httpPost({
|
|
208
|
+
url,
|
|
209
|
+
headers: {
|
|
210
|
+
"Content-Type": "application/x-sentry-envelope",
|
|
211
|
+
"X-Sentry-Auth": authHeader
|
|
212
|
+
},
|
|
213
|
+
body,
|
|
214
|
+
timeout: config.timeout ?? 5e3,
|
|
215
|
+
label: "Sentry"
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
//#endregion
|
|
220
|
+
export { createSentryDrain, sendBatchToSentry, sendToSentry, toSentryLog };
|
|
221
|
+
//# sourceMappingURL=sentry.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sentry.mjs","names":[],"sources":["../../src/adapters/sentry.ts"],"sourcesContent":["import type { WideEvent } from '../types'\nimport type { ConfigField } from './_config'\nimport { resolveAdapterConfig } from './_config'\nimport { defineDrain } from './_drain'\nimport { httpPost } from './_http'\nimport { OTEL_SEVERITY_NUMBER } from './_severity'\n\nexport interface SentryConfig {\n /** Sentry DSN */\n dsn: string\n /** Environment override (defaults to event.environment) */\n environment?: string\n /** Release version override (defaults to event.version) */\n release?: string\n /** Additional tags to attach as attributes */\n tags?: Record<string, string>\n /** Request timeout in milliseconds. Default: 5000 */\n timeout?: number\n}\n\n/** Sentry Log attribute value with type annotation */\nexport interface SentryAttributeValue {\n value: string | number | boolean\n type: 'string' | 'integer' | 'double' | 'boolean'\n}\n\n/** Sentry Structured Log payload */\nexport interface SentryLog {\n timestamp: number\n trace_id: string\n level: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'\n body: string\n severity_number: number\n attributes?: Record<string, SentryAttributeValue>\n}\n\ninterface SentryDsnParts {\n publicKey: string\n secretKey?: string\n projectId: string\n origin: string\n basePath: string\n}\n\nconst SENTRY_FIELDS: ConfigField<SentryConfig>[] = [\n { key: 'dsn', env: ['NUXT_SENTRY_DSN', 'SENTRY_DSN'] },\n { key: 'environment', env: ['NUXT_SENTRY_ENVIRONMENT', 'SENTRY_ENVIRONMENT'] },\n { key: 'release', env: ['NUXT_SENTRY_RELEASE', 'SENTRY_RELEASE'] },\n { key: 'tags' },\n { key: 'timeout' },\n]\n\nfunction parseSentryDsn(dsn: string): SentryDsnParts {\n const url = new URL(dsn)\n const publicKey = url.username\n if (!publicKey) {\n throw new Error('Invalid Sentry DSN: missing public key')\n }\n\n const secretKey = url.password || undefined\n\n const pathParts = url.pathname.split('/').filter(Boolean)\n const projectId = pathParts.pop()\n if (!projectId) {\n throw new Error('Invalid Sentry DSN: missing project ID')\n }\n\n const basePath = pathParts.length > 0 ? `/${pathParts.join('/')}` : ''\n\n return {\n publicKey,\n secretKey,\n projectId,\n origin: `${url.protocol}//${url.host}`,\n basePath,\n }\n}\n\nfunction getSentryEnvelopeUrl(dsn: string): { url: string, authHeader: string } {\n const { publicKey, secretKey, projectId, origin, basePath } = parseSentryDsn(dsn)\n const url = `${origin}${basePath}/api/${projectId}/envelope/`\n let authHeader = `Sentry sentry_version=7, sentry_key=${publicKey}, sentry_client=mxllog`\n if (secretKey) {\n authHeader += `, sentry_secret=${secretKey}`\n }\n return { url, authHeader }\n}\n\nfunction createTraceId(): string {\n if (typeof globalThis.crypto?.randomUUID === 'function') {\n return globalThis.crypto.randomUUID().replace(/-/g, '')\n }\n\n return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join('')\n}\n\nfunction getFirstStringValue(event: WideEvent, keys: string[]): string | undefined {\n for (const key of keys) {\n const value = event[key]\n if (typeof value === 'string' && value.length > 0) return value\n }\n return undefined\n}\n\nfunction toAttributeValue(value: unknown): SentryAttributeValue | undefined {\n if (value === null || value === undefined) {\n return undefined\n }\n if (typeof value === 'string') {\n return { value, type: 'string' }\n }\n if (typeof value === 'boolean') {\n return { value, type: 'boolean' }\n }\n if (typeof value === 'number') {\n if (Number.isInteger(value)) {\n return { value, type: 'integer' }\n }\n return { value, type: 'double' }\n }\n return { value: JSON.stringify(value), type: 'string' }\n}\n\nexport function toSentryLog(event: WideEvent, config: SentryConfig): SentryLog {\n const { timestamp, level, service, environment, version, ...rest } = event\n\n const body = getFirstStringValue(event, ['message', 'action', 'path'])\n ?? '@safaricom-mxl/log wide event'\n\n const traceId = (typeof event.traceId === 'string' && event.traceId.length > 0)\n ? event.traceId\n : createTraceId()\n\n const attributes: Record<string, SentryAttributeValue> = {}\n\n const env = config.environment ?? environment\n if (env) {\n attributes['sentry.environment'] = { value: env, type: 'string' }\n }\n\n const rel = config.release ?? version\n if (typeof rel === 'string' && rel.length > 0) {\n attributes['sentry.release'] = { value: rel, type: 'string' }\n }\n\n attributes['service'] = { value: service, type: 'string' }\n\n if (config.tags) {\n for (const [key, value] of Object.entries(config.tags)) {\n attributes[key] = { value, type: 'string' }\n }\n }\n\n for (const [key, value] of Object.entries(rest)) {\n if (key === 'traceId' || key === 'spanId') continue\n if (value === undefined || value === null) continue\n const attr = toAttributeValue(value)\n if (attr) {\n attributes[key] = attr\n }\n }\n\n return {\n timestamp: new Date(timestamp).getTime() / 1000,\n trace_id: traceId,\n level: level as SentryLog['level'],\n body,\n severity_number: OTEL_SEVERITY_NUMBER[level] ?? 9,\n attributes,\n }\n}\n\n/**\n * Build the Sentry Envelope body for a list of logs.\n *\n * Envelope format (line-delimited):\n * - Line 1: Envelope headers (dsn, sent_at)\n * - Line 2: Item header (type: log, item_count, content_type)\n * - Line 3: Item payload ({\"items\": [...]})\n */\nfunction buildEnvelopeBody(logs: SentryLog[], dsn: string): string {\n const envelopeHeader = JSON.stringify({\n dsn,\n sent_at: new Date().toISOString(),\n })\n\n const itemHeader = JSON.stringify({\n type: 'log',\n item_count: logs.length,\n content_type: 'application/vnd.sentry.items.log+json',\n })\n\n const itemPayload = JSON.stringify({ items: logs })\n\n return `${envelopeHeader}\\n${itemHeader}\\n${itemPayload}\\n`\n}\n\n/**\n * Create a drain function for sending logs to Sentry.\n *\n * Sends wide events as Sentry Structured Logs, visible in Explore > Logs\n * in the Sentry dashboard.\n *\n * Configuration priority (highest to lowest):\n * 1. Overrides passed to createSentryDrain()\n * 2. runtimeConfig.mxllog.sentry\n * 3. runtimeConfig.sentry\n * 4. Environment variables: NUXT_SENTRY_*, SENTRY_*\n *\n * @example\n * ```ts\n * // Zero config - just set NUXT_SENTRY_DSN env var\n * nitroApp.hooks.hook('@safaricom-mxl/log:drain', createSentryDrain())\n *\n * // With overrides\n * nitroApp.hooks.hook('@safaricom-mxl/log:drain', createSentryDrain({\n * dsn: 'https://public@o0.ingest.sentry.io/123',\n * }))\n * ```\n */\nexport function createSentryDrain(overrides?: Partial<SentryConfig>) {\n return defineDrain<SentryConfig>({\n name: 'sentry',\n resolve: () => {\n const config = resolveAdapterConfig<SentryConfig>('sentry', SENTRY_FIELDS, overrides)\n if (!config.dsn) {\n console.error('[mxllog/sentry] Missing DSN. Set NUXT_SENTRY_DSN/SENTRY_DSN env var or pass to createSentryDrain()')\n return null\n }\n return config as SentryConfig\n },\n send: sendBatchToSentry,\n })\n}\n\n/**\n * Send a single event to Sentry as a structured log.\n *\n * @example\n * ```ts\n * await sendToSentry(event, {\n * dsn: process.env.SENTRY_DSN!,\n * })\n * ```\n */\nexport async function sendToSentry(event: WideEvent, config: SentryConfig): Promise<void> {\n await sendBatchToSentry([event], config)\n}\n\n/**\n * Send a batch of events to Sentry as structured logs via the Envelope endpoint.\n *\n * @example\n * ```ts\n * await sendBatchToSentry(events, {\n * dsn: process.env.SENTRY_DSN!,\n * })\n * ```\n */\nexport async function sendBatchToSentry(events: WideEvent[], config: SentryConfig): Promise<void> {\n if (events.length === 0) return\n\n const { url, authHeader } = getSentryEnvelopeUrl(config.dsn)\n\n const logs = events.map(event => toSentryLog(event, config))\n const body = buildEnvelopeBody(logs, config.dsn)\n\n await httpPost({\n url,\n headers: {\n 'Content-Type': 'application/x-sentry-envelope',\n 'X-Sentry-Auth': authHeader,\n },\n body,\n timeout: config.timeout ?? 5000,\n label: 'Sentry',\n })\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { DrainContext } from "./types.mjs";
|
|
2
|
+
import { DrainPipelineOptions, PipelineDrainFn } from "./pipeline.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/browser.d.ts
|
|
5
|
+
interface BrowserDrainConfig {
|
|
6
|
+
/** URL of the server ingest endpoint */
|
|
7
|
+
endpoint: string;
|
|
8
|
+
/** Custom headers sent with each fetch request (e.g. Authorization, X-API-Key). Not applied to sendBeacon — see `useBeacon`. */
|
|
9
|
+
headers?: Record<string, string>;
|
|
10
|
+
/** Request timeout in milliseconds. @default 5000 */
|
|
11
|
+
timeout?: number;
|
|
12
|
+
/** Use sendBeacon when the page is hidden. @default true */
|
|
13
|
+
useBeacon?: boolean;
|
|
14
|
+
}
|
|
15
|
+
interface BrowserLogDrainOptions {
|
|
16
|
+
/** Browser drain configuration (endpoint is required) */
|
|
17
|
+
drain: BrowserDrainConfig;
|
|
18
|
+
/** Pipeline configuration overrides */
|
|
19
|
+
pipeline?: DrainPipelineOptions<DrainContext>;
|
|
20
|
+
/** Auto-register visibilitychange flush listener. @default true */
|
|
21
|
+
autoFlush?: boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Create a low-level browser drain transport function.
|
|
25
|
+
*
|
|
26
|
+
* Returns a function compatible with `createDrainPipeline` that sends batches
|
|
27
|
+
* to the configured endpoint via `fetch` (with `keepalive: true`) or
|
|
28
|
+
* `navigator.sendBeacon` when the page is hidden.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```ts
|
|
32
|
+
* import { createBrowserDrain } from '@safaricom-mxl/log/browser'
|
|
33
|
+
* import { createDrainPipeline } from '@safaricom-mxl/log/pipeline'
|
|
34
|
+
*
|
|
35
|
+
* const pipeline = createDrainPipeline({ batch: { size: 50 } })
|
|
36
|
+
* const drain = pipeline(createBrowserDrain({ endpoint: '/api/logs' }))
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
declare function createBrowserDrain(config: BrowserDrainConfig): (batch: DrainContext[]) => Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Create a pre-composed browser log drain with pipeline, batching, and auto-flush.
|
|
42
|
+
*
|
|
43
|
+
* Returns a `PipelineDrainFn<DrainContext>` directly usable with `initLogger({ drain })`.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```ts
|
|
47
|
+
* import { initLogger, log } from '@safaricom-mxl/log'
|
|
48
|
+
* import { createBrowserLogDrain } from '@safaricom-mxl/log/browser'
|
|
49
|
+
*
|
|
50
|
+
* const drain = createBrowserLogDrain({
|
|
51
|
+
* drain: { endpoint: '/api/logs' },
|
|
52
|
+
* })
|
|
53
|
+
* initLogger({ drain })
|
|
54
|
+
*
|
|
55
|
+
* log.info({ action: 'page_view', path: location.pathname })
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
declare function createBrowserLogDrain(options: BrowserLogDrainOptions): PipelineDrainFn<DrainContext> & {
|
|
59
|
+
dispose: () => void;
|
|
60
|
+
};
|
|
61
|
+
//#endregion
|
|
62
|
+
export { BrowserDrainConfig, BrowserLogDrainOptions, createBrowserDrain, createBrowserLogDrain };
|
|
63
|
+
//# sourceMappingURL=browser.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"browser.d.mts","names":[],"sources":["../src/browser.ts"],"mappings":";;;;UAIiB,kBAAA;;EAEf,QAAA;EAFiC;EAIjC,OAAA,GAAU,MAAA;EAAM;EAEhB,OAAA;EAFA;EAIA,SAAA;AAAA;AAAA,UAGe,sBAAA;EAHN;EAKT,KAAA,EAAO,kBAAA;EAFQ;EAIf,QAAA,GAAW,oBAAA,CAAqB,YAAA;;EAEhC,SAAA;AAAA;;;;;;;;;;;;AAmBF;;;;;iBAAgB,kBAAA,CAAmB,MAAA,EAAQ,kBAAA,IAAsB,KAAA,EAAO,YAAA,OAAmB,OAAA;;;;;;;;;;AA8D3F;;;;;;;;;iBAAgB,qBAAA,CAAsB,OAAA,EAAS,sBAAA,GAAyB,eAAA,CAAgB,YAAA;EAAkB,OAAA;AAAA"}
|
package/dist/browser.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createDrainPipeline } from "./pipeline.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/browser.ts
|
|
4
|
+
/**
|
|
5
|
+
* Create a low-level browser drain transport function.
|
|
6
|
+
*
|
|
7
|
+
* Returns a function compatible with `createDrainPipeline` that sends batches
|
|
8
|
+
* to the configured endpoint via `fetch` (with `keepalive: true`) or
|
|
9
|
+
* `navigator.sendBeacon` when the page is hidden.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { createBrowserDrain } from '@safaricom-mxl/log/browser'
|
|
14
|
+
* import { createDrainPipeline } from '@safaricom-mxl/log/pipeline'
|
|
15
|
+
*
|
|
16
|
+
* const pipeline = createDrainPipeline({ batch: { size: 50 } })
|
|
17
|
+
* const drain = pipeline(createBrowserDrain({ endpoint: '/api/logs' }))
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
function createBrowserDrain(config) {
|
|
21
|
+
const { endpoint, headers: customHeaders, timeout = 5e3, useBeacon = true } = config;
|
|
22
|
+
return async (batch) => {
|
|
23
|
+
if (batch.length === 0) return;
|
|
24
|
+
const body = JSON.stringify(batch);
|
|
25
|
+
if (useBeacon && typeof document !== "undefined" && document.visibilityState === "hidden" && typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
|
|
26
|
+
if (!navigator.sendBeacon(endpoint, new Blob([body], { type: "application/json" }))) throw new Error("[mxllog/browser] sendBeacon failed — payload may exceed browser limit");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetch(endpoint, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: {
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
...customHeaders
|
|
37
|
+
},
|
|
38
|
+
body,
|
|
39
|
+
signal: controller.signal,
|
|
40
|
+
keepalive: true,
|
|
41
|
+
credentials: "same-origin"
|
|
42
|
+
});
|
|
43
|
+
if (!response.ok) throw new Error(`[mxllog/browser] Server responded with ${response.status}`);
|
|
44
|
+
} finally {
|
|
45
|
+
clearTimeout(timeoutId);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Create a pre-composed browser log drain with pipeline, batching, and auto-flush.
|
|
51
|
+
*
|
|
52
|
+
* Returns a `PipelineDrainFn<DrainContext>` directly usable with `initLogger({ drain })`.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```ts
|
|
56
|
+
* import { initLogger, log } from '@safaricom-mxl/log'
|
|
57
|
+
* import { createBrowserLogDrain } from '@safaricom-mxl/log/browser'
|
|
58
|
+
*
|
|
59
|
+
* const drain = createBrowserLogDrain({
|
|
60
|
+
* drain: { endpoint: '/api/logs' },
|
|
61
|
+
* })
|
|
62
|
+
* initLogger({ drain })
|
|
63
|
+
*
|
|
64
|
+
* log.info({ action: 'page_view', path: location.pathname })
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
function createBrowserLogDrain(options) {
|
|
68
|
+
const { autoFlush = true } = options;
|
|
69
|
+
const drain = createDrainPipeline({
|
|
70
|
+
batch: {
|
|
71
|
+
size: 25,
|
|
72
|
+
intervalMs: 2e3
|
|
73
|
+
},
|
|
74
|
+
retry: { maxAttempts: 2 },
|
|
75
|
+
...options.pipeline
|
|
76
|
+
})(createBrowserDrain(options.drain));
|
|
77
|
+
let onVisibilityChange;
|
|
78
|
+
if (autoFlush && typeof document !== "undefined") {
|
|
79
|
+
onVisibilityChange = () => {
|
|
80
|
+
if (document.visibilityState === "hidden") drain.flush();
|
|
81
|
+
};
|
|
82
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
83
|
+
}
|
|
84
|
+
drain.dispose = () => {
|
|
85
|
+
if (onVisibilityChange) {
|
|
86
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
87
|
+
onVisibilityChange = void 0;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
return drain;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
//#endregion
|
|
94
|
+
export { createBrowserDrain, createBrowserLogDrain };
|
|
95
|
+
//# sourceMappingURL=browser.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"browser.mjs","names":[],"sources":["../src/browser.ts"],"sourcesContent":["import type { DrainContext } from './types'\nimport type { DrainPipelineOptions, PipelineDrainFn } from './pipeline'\nimport { createDrainPipeline } from './pipeline'\n\nexport interface BrowserDrainConfig {\n /** URL of the server ingest endpoint */\n endpoint: string\n /** Custom headers sent with each fetch request (e.g. Authorization, X-API-Key). Not applied to sendBeacon — see `useBeacon`. */\n headers?: Record<string, string>\n /** Request timeout in milliseconds. @default 5000 */\n timeout?: number\n /** Use sendBeacon when the page is hidden. @default true */\n useBeacon?: boolean\n}\n\nexport interface BrowserLogDrainOptions {\n /** Browser drain configuration (endpoint is required) */\n drain: BrowserDrainConfig\n /** Pipeline configuration overrides */\n pipeline?: DrainPipelineOptions<DrainContext>\n /** Auto-register visibilitychange flush listener. @default true */\n autoFlush?: boolean\n}\n\n/**\n * Create a low-level browser drain transport function.\n *\n * Returns a function compatible with `createDrainPipeline` that sends batches\n * to the configured endpoint via `fetch` (with `keepalive: true`) or\n * `navigator.sendBeacon` when the page is hidden.\n *\n * @example\n * ```ts\n * import { createBrowserDrain } from '@safaricom-mxl/log/browser'\n * import { createDrainPipeline } from '@safaricom-mxl/log/pipeline'\n *\n * const pipeline = createDrainPipeline({ batch: { size: 50 } })\n * const drain = pipeline(createBrowserDrain({ endpoint: '/api/logs' }))\n * ```\n */\nexport function createBrowserDrain(config: BrowserDrainConfig): (batch: DrainContext[]) => Promise<void> {\n const { endpoint, headers: customHeaders, timeout = 5000, useBeacon = true } = config\n\n return async (batch: DrainContext[]): Promise<void> => {\n if (batch.length === 0) return\n\n const body = JSON.stringify(batch)\n\n if (\n useBeacon\n && typeof document !== 'undefined'\n && document.visibilityState === 'hidden'\n && typeof navigator !== 'undefined'\n && typeof navigator.sendBeacon === 'function'\n ) {\n const queued = navigator.sendBeacon(endpoint, new Blob([body], { type: 'application/json' }))\n if (!queued) {\n throw new Error('[mxllog/browser] sendBeacon failed — payload may exceed browser limit')\n }\n return\n }\n\n const controller = new AbortController()\n const timeoutId = setTimeout(() => controller.abort(), timeout)\n\n try {\n const response = await fetch(endpoint, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...customHeaders },\n body,\n signal: controller.signal,\n keepalive: true,\n credentials: 'same-origin',\n })\n\n if (!response.ok) {\n throw new Error(`[mxllog/browser] Server responded with ${response.status}`)\n }\n } finally {\n clearTimeout(timeoutId)\n }\n }\n}\n\n/**\n * Create a pre-composed browser log drain with pipeline, batching, and auto-flush.\n *\n * Returns a `PipelineDrainFn<DrainContext>` directly usable with `initLogger({ drain })`.\n *\n * @example\n * ```ts\n * import { initLogger, log } from '@safaricom-mxl/log'\n * import { createBrowserLogDrain } from '@safaricom-mxl/log/browser'\n *\n * const drain = createBrowserLogDrain({\n * drain: { endpoint: '/api/logs' },\n * })\n * initLogger({ drain })\n *\n * log.info({ action: 'page_view', path: location.pathname })\n * ```\n */\nexport function createBrowserLogDrain(options: BrowserLogDrainOptions): PipelineDrainFn<DrainContext> & { dispose: () => void } {\n const { autoFlush = true } = options\n\n const pipeline = createDrainPipeline<DrainContext>({\n batch: { size: 25, intervalMs: 2000 },\n retry: { maxAttempts: 2 },\n ...options.pipeline,\n })\n\n const drain = pipeline(createBrowserDrain(options.drain)) as PipelineDrainFn<DrainContext> & { dispose: () => void }\n\n let onVisibilityChange: (() => void) | undefined\n\n if (autoFlush && typeof document !== 'undefined') {\n onVisibilityChange = () => {\n if (document.visibilityState === 'hidden') {\n drain.flush()\n }\n }\n document.addEventListener('visibilitychange', onVisibilityChange)\n }\n\n drain.dispose = () => {\n if (onVisibilityChange) {\n document.removeEventListener('visibilitychange', onVisibilityChange)\n onVisibilityChange = undefined\n }\n }\n\n return drain\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAwCA,SAAgB,mBAAmB,QAAsE;CACvG,MAAM,EAAE,UAAU,SAAS,eAAe,UAAU,KAAM,YAAY,SAAS;AAE/E,QAAO,OAAO,UAAyC;AACrD,MAAI,MAAM,WAAW,EAAG;EAExB,MAAM,OAAO,KAAK,UAAU,MAAM;AAElC,MACE,aACG,OAAO,aAAa,eACpB,SAAS,oBAAoB,YAC7B,OAAO,cAAc,eACrB,OAAO,UAAU,eAAe,YACnC;AAEA,OAAI,CADW,UAAU,WAAW,UAAU,IAAI,KAAK,CAAC,KAAK,EAAE,EAAE,MAAM,oBAAoB,CAAC,CAAC,CAE3F,OAAM,IAAI,MAAM,wEAAwE;AAE1F;;EAGF,MAAM,aAAa,IAAI,iBAAiB;EACxC,MAAM,YAAY,iBAAiB,WAAW,OAAO,EAAE,QAAQ;AAE/D,MAAI;GACF,MAAM,WAAW,MAAM,MAAM,UAAU;IACrC,QAAQ;IACR,SAAS;KAAE,gBAAgB;KAAoB,GAAG;KAAe;IACjE;IACA,QAAQ,WAAW;IACnB,WAAW;IACX,aAAa;IACd,CAAC;AAEF,OAAI,CAAC,SAAS,GACZ,OAAM,IAAI,MAAM,0CAA0C,SAAS,SAAS;YAEtE;AACR,gBAAa,UAAU;;;;;;;;;;;;;;;;;;;;;;AAuB7B,SAAgB,sBAAsB,SAA0F;CAC9H,MAAM,EAAE,YAAY,SAAS;CAQ7B,MAAM,QANW,oBAAkC;EACjD,OAAO;GAAE,MAAM;GAAI,YAAY;GAAM;EACrC,OAAO,EAAE,aAAa,GAAG;EACzB,GAAG,QAAQ;EACZ,CAAC,CAEqB,mBAAmB,QAAQ,MAAM,CAAC;CAEzD,IAAI;AAEJ,KAAI,aAAa,OAAO,aAAa,aAAa;AAChD,6BAA2B;AACzB,OAAI,SAAS,oBAAoB,SAC/B,OAAM,OAAO;;AAGjB,WAAS,iBAAiB,oBAAoB,mBAAmB;;AAGnE,OAAM,gBAAgB;AACpB,MAAI,oBAAoB;AACtB,YAAS,oBAAoB,oBAAoB,mBAAmB;AACpE,wBAAqB;;;AAIzB,QAAO"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { EnrichContext } from "./types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/enrichers/index.d.ts
|
|
4
|
+
interface EnricherOptions {
|
|
5
|
+
/**
|
|
6
|
+
* When true, overwrite any existing fields in the event.
|
|
7
|
+
* Defaults to false to preserve user-provided data.
|
|
8
|
+
*/
|
|
9
|
+
overwrite?: boolean;
|
|
10
|
+
}
|
|
11
|
+
interface UserAgentInfo {
|
|
12
|
+
raw: string;
|
|
13
|
+
browser?: {
|
|
14
|
+
name: string;
|
|
15
|
+
version?: string;
|
|
16
|
+
};
|
|
17
|
+
os?: {
|
|
18
|
+
name: string;
|
|
19
|
+
version?: string;
|
|
20
|
+
};
|
|
21
|
+
device?: {
|
|
22
|
+
type: 'mobile' | 'tablet' | 'desktop' | 'bot' | 'unknown';
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
interface GeoInfo {
|
|
26
|
+
country?: string;
|
|
27
|
+
region?: string;
|
|
28
|
+
regionCode?: string;
|
|
29
|
+
city?: string;
|
|
30
|
+
latitude?: number;
|
|
31
|
+
longitude?: number;
|
|
32
|
+
}
|
|
33
|
+
interface RequestSizeInfo {
|
|
34
|
+
requestBytes?: number;
|
|
35
|
+
responseBytes?: number;
|
|
36
|
+
}
|
|
37
|
+
interface TraceContextInfo {
|
|
38
|
+
traceparent?: string;
|
|
39
|
+
tracestate?: string;
|
|
40
|
+
traceId?: string;
|
|
41
|
+
spanId?: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Enrich events with parsed user agent data.
|
|
45
|
+
* Sets `event.userAgent` with `UserAgentInfo` shape: `{ raw, browser?, os?, device? }`.
|
|
46
|
+
*/
|
|
47
|
+
declare function createUserAgentEnricher(options?: EnricherOptions): (ctx: EnrichContext) => void;
|
|
48
|
+
/**
|
|
49
|
+
* Enrich events with geo data from platform headers.
|
|
50
|
+
* Sets `event.geo` with `GeoInfo` shape: `{ country?, region?, regionCode?, city?, latitude?, longitude? }`.
|
|
51
|
+
*
|
|
52
|
+
* Supports Vercel (`x-vercel-ip-*`) headers out of the box.
|
|
53
|
+
*
|
|
54
|
+
* **Cloudflare note:** Only `cf-ipcountry` is an actual HTTP header added by Cloudflare.
|
|
55
|
+
* The `cf-region`, `cf-city`, `cf-latitude`, `cf-longitude` headers are NOT standard
|
|
56
|
+
* Cloudflare headers — they are properties of `request.cf` which is not exposed as HTTP
|
|
57
|
+
* headers. For full geo data on Cloudflare, write a custom enricher that reads `request.cf`
|
|
58
|
+
* or use a Workers middleware to copy `cf` properties into custom headers.
|
|
59
|
+
*/
|
|
60
|
+
declare function createGeoEnricher(options?: EnricherOptions): (ctx: EnrichContext) => void;
|
|
61
|
+
/**
|
|
62
|
+
* Enrich events with request/response payload sizes.
|
|
63
|
+
* Sets `event.requestSize` with `RequestSizeInfo` shape: `{ requestBytes?, responseBytes? }`.
|
|
64
|
+
*/
|
|
65
|
+
declare function createRequestSizeEnricher(options?: EnricherOptions): (ctx: EnrichContext) => void;
|
|
66
|
+
/**
|
|
67
|
+
* Enrich events with W3C trace context data.
|
|
68
|
+
* Sets `event.traceContext` with `TraceContextInfo` shape: `{ traceparent?, tracestate?, traceId?, spanId? }`.
|
|
69
|
+
* Also sets `event.traceId` and `event.spanId` at the top level.
|
|
70
|
+
*/
|
|
71
|
+
declare function createTraceContextEnricher(options?: EnricherOptions): (ctx: EnrichContext) => void;
|
|
72
|
+
//#endregion
|
|
73
|
+
export { EnricherOptions, GeoInfo, RequestSizeInfo, TraceContextInfo, UserAgentInfo, createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, createUserAgentEnricher };
|
|
74
|
+
//# sourceMappingURL=enrichers.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"enrichers.d.mts","names":[],"sources":["../src/enrichers/index.ts"],"mappings":";;;UAEiB,eAAA;;AAAjB;;;EAKE,SAAA;AAAA;AAAA,UAGe,aAAA;EACf,GAAA;EACA,OAAA;IAAY,IAAA;IAAc,OAAA;EAAA;EAC1B,EAAA;IAAO,IAAA;IAAc,OAAA;EAAA;EACrB,MAAA;IAAW,IAAA;EAAA;AAAA;AAAA,UAGI,OAAA;EACf,OAAA;EACA,MAAA;EACA,UAAA;EACA,IAAA;EACA,QAAA;EACA,SAAA;AAAA;AAAA,UAGe,eAAA;EACf,YAAA;EACA,aAAA;AAAA;AAAA,UAGe,gBAAA;EACf,WAAA;EACA,UAAA;EACA,OAAA;EACA,MAAA;AAAA;AAJF;;;;AAAA,iBAoGgB,uBAAA,CAAwB,OAAA,GAAS,eAAA,IAAwB,GAAA,EAAK,aAAA;;;;;;AAA9E;;;;;;;iBAqBgB,iBAAA,CAAkB,OAAA,GAAS,eAAA,IAAwB,GAAA,EAAK,aAAA;;;AAAxE;;iBAuBgB,yBAAA,CAA0B,OAAA,GAAS,eAAA,IAAwB,GAAA,EAAK,aAAA;;;;;;iBAoBhE,0BAAA,CAA2B,OAAA,GAAS,eAAA,IAAwB,GAAA,EAAK,aAAA"}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
//#region src/enrichers/index.ts
|
|
2
|
+
function getHeader(headers, name) {
|
|
3
|
+
if (!headers) return void 0;
|
|
4
|
+
if (headers[name] !== void 0) return headers[name];
|
|
5
|
+
const lowerName = name.toLowerCase();
|
|
6
|
+
if (headers[lowerName] !== void 0) return headers[lowerName];
|
|
7
|
+
for (const [key, value] of Object.entries(headers)) if (key.toLowerCase() === lowerName) return value;
|
|
8
|
+
}
|
|
9
|
+
function parseUserAgent(ua) {
|
|
10
|
+
const lower = ua.toLowerCase();
|
|
11
|
+
let deviceType = { type: "unknown" };
|
|
12
|
+
if (/bot|crawl|spider|slurp|bingpreview/.test(lower)) deviceType = { type: "bot" };
|
|
13
|
+
else if (/ipad|tablet/.test(lower)) deviceType = { type: "tablet" };
|
|
14
|
+
else if (/mobi|iphone|android/.test(lower)) deviceType = { type: "mobile" };
|
|
15
|
+
else if (ua.length > 0) deviceType = { type: "desktop" };
|
|
16
|
+
const browserMatchers = [
|
|
17
|
+
{
|
|
18
|
+
name: "Edge",
|
|
19
|
+
regex: /edg\/([\d.]+)/i
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: "Chrome",
|
|
23
|
+
regex: /chrome\/([\d.]+)/i
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "Firefox",
|
|
27
|
+
regex: /firefox\/([\d.]+)/i
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "Safari",
|
|
31
|
+
regex: /version\/([\d.]+).*safari/i
|
|
32
|
+
}
|
|
33
|
+
];
|
|
34
|
+
let browser;
|
|
35
|
+
for (const matcher of browserMatchers) {
|
|
36
|
+
const match = ua.match(matcher.regex);
|
|
37
|
+
if (match) {
|
|
38
|
+
browser = {
|
|
39
|
+
name: matcher.name,
|
|
40
|
+
version: match[1]
|
|
41
|
+
};
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
let os;
|
|
46
|
+
if (/windows nt/i.test(ua)) os = {
|
|
47
|
+
name: "Windows",
|
|
48
|
+
version: ua.match(/windows nt ([\d.]+)/i)?.[1]
|
|
49
|
+
};
|
|
50
|
+
else if (/mac os x/i.test(ua) && !/iphone|ipad|ipod/i.test(ua)) os = {
|
|
51
|
+
name: "macOS",
|
|
52
|
+
version: ua.match(/mac os x ([\d_]+)/i)?.[1]?.replace(/_/g, ".")
|
|
53
|
+
};
|
|
54
|
+
else if (/iphone|ipad|ipod/i.test(ua)) os = {
|
|
55
|
+
name: "iOS",
|
|
56
|
+
version: ua.match(/os ([\d_]+)/i)?.[1]?.replace(/_/g, ".")
|
|
57
|
+
};
|
|
58
|
+
else if (/android/i.test(ua)) os = {
|
|
59
|
+
name: "Android",
|
|
60
|
+
version: ua.match(/android ([\d.]+)/i)?.[1]
|
|
61
|
+
};
|
|
62
|
+
else if (/linux/i.test(ua)) os = { name: "Linux" };
|
|
63
|
+
return {
|
|
64
|
+
raw: ua,
|
|
65
|
+
browser,
|
|
66
|
+
os,
|
|
67
|
+
device: deviceType
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function parseTraceparent(traceparent) {
|
|
71
|
+
const match = traceparent.match(/^[\da-f]{2}-([\da-f]{32})-([\da-f]{16})-[\da-f]{2}$/i);
|
|
72
|
+
if (!match) return void 0;
|
|
73
|
+
return {
|
|
74
|
+
traceId: match[1],
|
|
75
|
+
spanId: match[2]
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function mergeEventField(existing, computed, overwrite) {
|
|
79
|
+
if (overwrite || existing === void 0 || existing === null || typeof existing !== "object") return computed;
|
|
80
|
+
return {
|
|
81
|
+
...computed,
|
|
82
|
+
...existing
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function normalizeNumber(value) {
|
|
86
|
+
if (!value) return void 0;
|
|
87
|
+
const parsed = Number(value);
|
|
88
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Enrich events with parsed user agent data.
|
|
92
|
+
* Sets `event.userAgent` with `UserAgentInfo` shape: `{ raw, browser?, os?, device? }`.
|
|
93
|
+
*/
|
|
94
|
+
function createUserAgentEnricher(options = {}) {
|
|
95
|
+
return (ctx) => {
|
|
96
|
+
const ua = getHeader(ctx.headers, "user-agent");
|
|
97
|
+
if (!ua) return;
|
|
98
|
+
const info = parseUserAgent(ua);
|
|
99
|
+
ctx.event.userAgent = mergeEventField(ctx.event.userAgent, info, options.overwrite);
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Enrich events with geo data from platform headers.
|
|
104
|
+
* Sets `event.geo` with `GeoInfo` shape: `{ country?, region?, regionCode?, city?, latitude?, longitude? }`.
|
|
105
|
+
*
|
|
106
|
+
* Supports Vercel (`x-vercel-ip-*`) headers out of the box.
|
|
107
|
+
*
|
|
108
|
+
* **Cloudflare note:** Only `cf-ipcountry` is an actual HTTP header added by Cloudflare.
|
|
109
|
+
* The `cf-region`, `cf-city`, `cf-latitude`, `cf-longitude` headers are NOT standard
|
|
110
|
+
* Cloudflare headers — they are properties of `request.cf` which is not exposed as HTTP
|
|
111
|
+
* headers. For full geo data on Cloudflare, write a custom enricher that reads `request.cf`
|
|
112
|
+
* or use a Workers middleware to copy `cf` properties into custom headers.
|
|
113
|
+
*/
|
|
114
|
+
function createGeoEnricher(options = {}) {
|
|
115
|
+
return (ctx) => {
|
|
116
|
+
const { headers } = ctx;
|
|
117
|
+
if (!headers) return;
|
|
118
|
+
const geo = {
|
|
119
|
+
country: getHeader(headers, "x-vercel-ip-country") ?? getHeader(headers, "cf-ipcountry"),
|
|
120
|
+
region: getHeader(headers, "x-vercel-ip-country-region") ?? getHeader(headers, "cf-region"),
|
|
121
|
+
regionCode: getHeader(headers, "x-vercel-ip-country-region-code") ?? getHeader(headers, "cf-region-code"),
|
|
122
|
+
city: getHeader(headers, "x-vercel-ip-city") ?? getHeader(headers, "cf-city"),
|
|
123
|
+
latitude: normalizeNumber(getHeader(headers, "x-vercel-ip-latitude") ?? getHeader(headers, "cf-latitude")),
|
|
124
|
+
longitude: normalizeNumber(getHeader(headers, "x-vercel-ip-longitude") ?? getHeader(headers, "cf-longitude"))
|
|
125
|
+
};
|
|
126
|
+
if (Object.values(geo).every((value) => value === void 0)) return;
|
|
127
|
+
ctx.event.geo = mergeEventField(ctx.event.geo, geo, options.overwrite);
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Enrich events with request/response payload sizes.
|
|
132
|
+
* Sets `event.requestSize` with `RequestSizeInfo` shape: `{ requestBytes?, responseBytes? }`.
|
|
133
|
+
*/
|
|
134
|
+
function createRequestSizeEnricher(options = {}) {
|
|
135
|
+
return (ctx) => {
|
|
136
|
+
const requestBytes = normalizeNumber(getHeader(ctx.headers, "content-length"));
|
|
137
|
+
const responseBytes = normalizeNumber(getHeader(ctx.response?.headers, "content-length"));
|
|
138
|
+
const sizes = {
|
|
139
|
+
requestBytes,
|
|
140
|
+
responseBytes
|
|
141
|
+
};
|
|
142
|
+
if (requestBytes === void 0 && responseBytes === void 0) return;
|
|
143
|
+
ctx.event.requestSize = mergeEventField(ctx.event.requestSize, sizes, options.overwrite);
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Enrich events with W3C trace context data.
|
|
148
|
+
* Sets `event.traceContext` with `TraceContextInfo` shape: `{ traceparent?, tracestate?, traceId?, spanId? }`.
|
|
149
|
+
* Also sets `event.traceId` and `event.spanId` at the top level.
|
|
150
|
+
*/
|
|
151
|
+
function createTraceContextEnricher(options = {}) {
|
|
152
|
+
return (ctx) => {
|
|
153
|
+
const traceparent = getHeader(ctx.headers, "traceparent");
|
|
154
|
+
const tracestate = getHeader(ctx.headers, "tracestate");
|
|
155
|
+
if (!traceparent && !tracestate) return;
|
|
156
|
+
const parsed = traceparent ? parseTraceparent(traceparent) : void 0;
|
|
157
|
+
const incomingTraceContext = {
|
|
158
|
+
traceparent,
|
|
159
|
+
tracestate,
|
|
160
|
+
traceId: parsed?.traceId ?? ctx.event.traceId,
|
|
161
|
+
spanId: parsed?.spanId ?? ctx.event.spanId
|
|
162
|
+
};
|
|
163
|
+
const mergedTraceContext = mergeEventField(ctx.event.traceContext, incomingTraceContext, options.overwrite);
|
|
164
|
+
ctx.event.traceContext = mergedTraceContext;
|
|
165
|
+
if (mergedTraceContext.traceId && (options.overwrite || ctx.event.traceId === void 0)) ctx.event.traceId = mergedTraceContext.traceId;
|
|
166
|
+
if (mergedTraceContext.spanId && (options.overwrite || ctx.event.spanId === void 0)) ctx.event.spanId = mergedTraceContext.spanId;
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
//#endregion
|
|
171
|
+
export { createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, createUserAgentEnricher };
|
|
172
|
+
//# sourceMappingURL=enrichers.mjs.map
|