evlog 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -346,6 +346,49 @@ async function processSyncJob(job: Job) {
346
346
  }
347
347
  ```
348
348
 
349
+ ## Cloudflare Workers
350
+
351
+ Use the Workers adapter for structured logs and correct platform severity.
352
+
353
+ ```typescript
354
+ // src/index.ts
355
+ import { initWorkersLogger, createWorkersLogger } from 'evlog/workers'
356
+
357
+ initWorkersLogger({
358
+ env: { service: 'edge-api' },
359
+ })
360
+
361
+ export default {
362
+ async fetch(request: Request) {
363
+ const log = createWorkersLogger(request)
364
+
365
+ try {
366
+ log.set({ route: 'health' })
367
+ const response = new Response('ok', { status: 200 })
368
+ log.emit({ status: response.status })
369
+ return response
370
+ } catch (error) {
371
+ log.error(error as Error)
372
+ log.emit({ status: 500 })
373
+ throw error
374
+ }
375
+ },
376
+ }
377
+ ```
378
+
379
+ Disable invocation logs to avoid duplicate request logs:
380
+
381
+ ```toml
382
+ # wrangler.toml
383
+ [observability.logs]
384
+ invocation_logs = false
385
+ ```
386
+
387
+ Notes:
388
+ - `requestId` defaults to `cf-ray` when available
389
+ - `request.cf` is included (colo, country, asn) unless disabled
390
+ - Use `headerAllowlist` to avoid logging sensitive headers
391
+
349
392
  ## API Reference
350
393
 
351
394
  ### `initLogger(config)`
@@ -362,6 +405,7 @@ initLogger({
362
405
  region?: string // Deployment region
363
406
  },
364
407
  pretty?: boolean // Pretty print (default: true in dev)
408
+ stringify?: boolean // JSON.stringify output (default: true, false for Workers)
365
409
  include?: string[] // Route patterns to log (glob), e.g. ['/api/**']
366
410
  sampling?: {
367
411
  rates?: { // Head sampling (random per level)
@@ -479,6 +523,34 @@ log.emit() // Emit final event
479
523
  log.getContext() // Get current context
480
524
  ```
481
525
 
526
+ ### `initWorkersLogger(options?)`
527
+
528
+ Initialize evlog for Cloudflare Workers (object logs + correct severity).
529
+
530
+ ```typescript
531
+ import { initWorkersLogger } from 'evlog/workers'
532
+
533
+ initWorkersLogger({
534
+ env: { service: 'edge-api' },
535
+ })
536
+ ```
537
+
538
+ ### `createWorkersLogger(request, options?)`
539
+
540
+ Create a request-scoped logger for Workers. Auto-extracts `cf-ray`, `request.cf`, method, and path.
541
+
542
+ ```typescript
543
+ import { createWorkersLogger } from 'evlog/workers'
544
+
545
+ const log = createWorkersLogger(request, {
546
+ requestId: 'custom-id', // Override cf-ray (default: cf-ray header)
547
+ headers: ['x-request-id'], // Headers to include (default: none)
548
+ })
549
+
550
+ log.set({ user: { id: '123' } })
551
+ log.emit({ status: 200 })
552
+ ```
553
+
482
554
  ### `createError(options)`
483
555
 
484
556
  Create a structured error with HTTP status support. Import from `evlog` directly to avoid conflicts with Nuxt/Nitro's `createError`.
@@ -0,0 +1,62 @@
1
+ import { DrainContext, WideEvent } from '../types.mjs';
2
+
3
+ interface AxiomConfig {
4
+ /** Axiom dataset name */
5
+ dataset: string;
6
+ /** Axiom API token */
7
+ token: string;
8
+ /** Organization ID (required for Personal Access Tokens) */
9
+ orgId?: string;
10
+ /** Base URL for Axiom API. Default: https://api.axiom.co */
11
+ baseUrl?: string;
12
+ /** Request timeout in milliseconds. Default: 5000 */
13
+ timeout?: number;
14
+ }
15
+ /**
16
+ * Create a drain function for sending logs to Axiom.
17
+ *
18
+ * Configuration priority (highest to lowest):
19
+ * 1. Overrides passed to createAxiomDrain()
20
+ * 2. runtimeConfig.evlog.axiom
21
+ * 3. runtimeConfig.axiom
22
+ * 4. Environment variables: NUXT_AXIOM_*, AXIOM_*
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * // Zero config - just set NUXT_AXIOM_TOKEN and NUXT_AXIOM_DATASET env vars
27
+ * nitroApp.hooks.hook('evlog:drain', createAxiomDrain())
28
+ *
29
+ * // With overrides
30
+ * nitroApp.hooks.hook('evlog:drain', createAxiomDrain({
31
+ * dataset: 'my-dataset',
32
+ * }))
33
+ * ```
34
+ */
35
+ declare function createAxiomDrain(overrides?: Partial<AxiomConfig>): (ctx: DrainContext) => Promise<void>;
36
+ /**
37
+ * Send a single event to Axiom.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * await sendToAxiom(event, {
42
+ * dataset: 'my-logs',
43
+ * token: process.env.AXIOM_TOKEN!,
44
+ * })
45
+ * ```
46
+ */
47
+ declare function sendToAxiom(event: WideEvent, config: AxiomConfig): Promise<void>;
48
+ /**
49
+ * Send a batch of events to Axiom.
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * await sendBatchToAxiom(events, {
54
+ * dataset: 'my-logs',
55
+ * token: process.env.AXIOM_TOKEN!,
56
+ * })
57
+ * ```
58
+ */
59
+ declare function sendBatchToAxiom(events: WideEvent[], config: AxiomConfig): Promise<void>;
60
+
61
+ export { createAxiomDrain, sendBatchToAxiom, sendToAxiom };
62
+ export type { AxiomConfig };
@@ -0,0 +1,62 @@
1
+ import { DrainContext, WideEvent } from '../types.js';
2
+
3
+ interface AxiomConfig {
4
+ /** Axiom dataset name */
5
+ dataset: string;
6
+ /** Axiom API token */
7
+ token: string;
8
+ /** Organization ID (required for Personal Access Tokens) */
9
+ orgId?: string;
10
+ /** Base URL for Axiom API. Default: https://api.axiom.co */
11
+ baseUrl?: string;
12
+ /** Request timeout in milliseconds. Default: 5000 */
13
+ timeout?: number;
14
+ }
15
+ /**
16
+ * Create a drain function for sending logs to Axiom.
17
+ *
18
+ * Configuration priority (highest to lowest):
19
+ * 1. Overrides passed to createAxiomDrain()
20
+ * 2. runtimeConfig.evlog.axiom
21
+ * 3. runtimeConfig.axiom
22
+ * 4. Environment variables: NUXT_AXIOM_*, AXIOM_*
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * // Zero config - just set NUXT_AXIOM_TOKEN and NUXT_AXIOM_DATASET env vars
27
+ * nitroApp.hooks.hook('evlog:drain', createAxiomDrain())
28
+ *
29
+ * // With overrides
30
+ * nitroApp.hooks.hook('evlog:drain', createAxiomDrain({
31
+ * dataset: 'my-dataset',
32
+ * }))
33
+ * ```
34
+ */
35
+ declare function createAxiomDrain(overrides?: Partial<AxiomConfig>): (ctx: DrainContext) => Promise<void>;
36
+ /**
37
+ * Send a single event to Axiom.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * await sendToAxiom(event, {
42
+ * dataset: 'my-logs',
43
+ * token: process.env.AXIOM_TOKEN!,
44
+ * })
45
+ * ```
46
+ */
47
+ declare function sendToAxiom(event: WideEvent, config: AxiomConfig): Promise<void>;
48
+ /**
49
+ * Send a batch of events to Axiom.
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * await sendBatchToAxiom(events, {
54
+ * dataset: 'my-logs',
55
+ * token: process.env.AXIOM_TOKEN!,
56
+ * })
57
+ * ```
58
+ */
59
+ declare function sendBatchToAxiom(events: WideEvent[], config: AxiomConfig): Promise<void>;
60
+
61
+ export { createAxiomDrain, sendBatchToAxiom, sendToAxiom };
62
+ export type { AxiomConfig };
@@ -0,0 +1,65 @@
1
+ function getRuntimeConfig() {
2
+ try {
3
+ const { useRuntimeConfig } = require("nitropack/runtime");
4
+ return useRuntimeConfig();
5
+ } catch {
6
+ return void 0;
7
+ }
8
+ }
9
+ function createAxiomDrain(overrides) {
10
+ return async (ctx) => {
11
+ const runtimeConfig = getRuntimeConfig();
12
+ const evlogAxiom = runtimeConfig?.evlog?.axiom;
13
+ const rootAxiom = runtimeConfig?.axiom;
14
+ const config = {
15
+ dataset: overrides?.dataset ?? evlogAxiom?.dataset ?? rootAxiom?.dataset ?? process.env.NUXT_AXIOM_DATASET ?? process.env.AXIOM_DATASET,
16
+ token: overrides?.token ?? evlogAxiom?.token ?? rootAxiom?.token ?? process.env.NUXT_AXIOM_TOKEN ?? process.env.AXIOM_TOKEN,
17
+ orgId: overrides?.orgId ?? evlogAxiom?.orgId ?? rootAxiom?.orgId ?? process.env.NUXT_AXIOM_ORG_ID ?? process.env.AXIOM_ORG_ID,
18
+ baseUrl: overrides?.baseUrl ?? evlogAxiom?.baseUrl ?? rootAxiom?.baseUrl ?? process.env.NUXT_AXIOM_URL ?? process.env.AXIOM_URL,
19
+ timeout: overrides?.timeout ?? evlogAxiom?.timeout ?? rootAxiom?.timeout
20
+ };
21
+ if (!config.dataset || !config.token) {
22
+ console.error("[evlog/axiom] Missing dataset or token. Set NUXT_AXIOM_TOKEN/NUXT_AXIOM_DATASET env vars or pass to createAxiomDrain()");
23
+ return;
24
+ }
25
+ try {
26
+ await sendToAxiom(ctx.event, config);
27
+ } catch (error) {
28
+ console.error("[evlog/axiom] Failed to send event:", error);
29
+ }
30
+ };
31
+ }
32
+ async function sendToAxiom(event, config) {
33
+ await sendBatchToAxiom([event], config);
34
+ }
35
+ async function sendBatchToAxiom(events, config) {
36
+ const baseUrl = config.baseUrl ?? "https://api.axiom.co";
37
+ const timeout = config.timeout ?? 5e3;
38
+ const url = `${baseUrl}/v1/datasets/${encodeURIComponent(config.dataset)}/ingest`;
39
+ const headers = {
40
+ "Content-Type": "application/json",
41
+ "Authorization": `Bearer ${config.token}`
42
+ };
43
+ if (config.orgId) {
44
+ headers["X-Axiom-Org-Id"] = config.orgId;
45
+ }
46
+ const controller = new AbortController();
47
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
48
+ try {
49
+ const response = await fetch(url, {
50
+ method: "POST",
51
+ headers,
52
+ body: JSON.stringify(events),
53
+ signal: controller.signal
54
+ });
55
+ if (!response.ok) {
56
+ const text = await response.text().catch(() => "Unknown error");
57
+ const safeText = text.length > 200 ? `${text.slice(0, 200)}...[truncated]` : text;
58
+ throw new Error(`Axiom API error: ${response.status} ${response.statusText} - ${safeText}`);
59
+ }
60
+ } finally {
61
+ clearTimeout(timeoutId);
62
+ }
63
+ }
64
+
65
+ export { createAxiomDrain, sendBatchToAxiom, sendToAxiom };
@@ -0,0 +1,83 @@
1
+ import { DrainContext, WideEvent } from '../types.mjs';
2
+
3
+ interface OTLPConfig {
4
+ /** OTLP HTTP endpoint (e.g., http://localhost:4318) */
5
+ endpoint: string;
6
+ /** Override service name (defaults to event.service) */
7
+ serviceName?: string;
8
+ /** Additional resource attributes */
9
+ resourceAttributes?: Record<string, string | number | boolean>;
10
+ /** Custom headers (e.g., for authentication) */
11
+ headers?: Record<string, string>;
12
+ /** Request timeout in milliseconds. Default: 5000 */
13
+ timeout?: number;
14
+ }
15
+ /** OTLP Log Record structure */
16
+ interface OTLPLogRecord {
17
+ timeUnixNano: string;
18
+ severityNumber: number;
19
+ severityText: string;
20
+ body: {
21
+ stringValue: string;
22
+ };
23
+ attributes: Array<{
24
+ key: string;
25
+ value: {
26
+ stringValue?: string;
27
+ intValue?: string;
28
+ boolValue?: boolean;
29
+ };
30
+ }>;
31
+ traceId?: string;
32
+ spanId?: string;
33
+ }
34
+ /**
35
+ * Convert an evlog WideEvent to an OTLP LogRecord.
36
+ */
37
+ declare function toOTLPLogRecord(event: WideEvent): OTLPLogRecord;
38
+ /**
39
+ * Create a drain function for sending logs to an OTLP endpoint.
40
+ *
41
+ * Configuration priority (highest to lowest):
42
+ * 1. Overrides passed to createOTLPDrain()
43
+ * 2. runtimeConfig.evlog.otlp (NUXT_EVLOG_OTLP_*)
44
+ * 3. runtimeConfig.otlp (NUXT_OTLP_*)
45
+ * 4. Environment variables: OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * // Zero config - reads from runtimeConfig or env vars
50
+ * nitroApp.hooks.hook('evlog:drain', createOTLPDrain())
51
+ *
52
+ * // With overrides
53
+ * nitroApp.hooks.hook('evlog:drain', createOTLPDrain({
54
+ * endpoint: 'http://localhost:4318',
55
+ * }))
56
+ * ```
57
+ */
58
+ declare function createOTLPDrain(overrides?: Partial<OTLPConfig>): (ctx: DrainContext) => Promise<void>;
59
+ /**
60
+ * Send a single event to an OTLP endpoint.
61
+ *
62
+ * @example
63
+ * ```ts
64
+ * await sendToOTLP(event, {
65
+ * endpoint: 'http://localhost:4318',
66
+ * })
67
+ * ```
68
+ */
69
+ declare function sendToOTLP(event: WideEvent, config: OTLPConfig): Promise<void>;
70
+ /**
71
+ * Send a batch of events to an OTLP endpoint.
72
+ *
73
+ * @example
74
+ * ```ts
75
+ * await sendBatchToOTLP(events, {
76
+ * endpoint: 'http://localhost:4318',
77
+ * })
78
+ * ```
79
+ */
80
+ declare function sendBatchToOTLP(events: WideEvent[], config: OTLPConfig): Promise<void>;
81
+
82
+ export { createOTLPDrain, sendBatchToOTLP, sendToOTLP, toOTLPLogRecord };
83
+ export type { OTLPConfig, OTLPLogRecord };
@@ -0,0 +1,83 @@
1
+ import { DrainContext, WideEvent } from '../types.js';
2
+
3
+ interface OTLPConfig {
4
+ /** OTLP HTTP endpoint (e.g., http://localhost:4318) */
5
+ endpoint: string;
6
+ /** Override service name (defaults to event.service) */
7
+ serviceName?: string;
8
+ /** Additional resource attributes */
9
+ resourceAttributes?: Record<string, string | number | boolean>;
10
+ /** Custom headers (e.g., for authentication) */
11
+ headers?: Record<string, string>;
12
+ /** Request timeout in milliseconds. Default: 5000 */
13
+ timeout?: number;
14
+ }
15
+ /** OTLP Log Record structure */
16
+ interface OTLPLogRecord {
17
+ timeUnixNano: string;
18
+ severityNumber: number;
19
+ severityText: string;
20
+ body: {
21
+ stringValue: string;
22
+ };
23
+ attributes: Array<{
24
+ key: string;
25
+ value: {
26
+ stringValue?: string;
27
+ intValue?: string;
28
+ boolValue?: boolean;
29
+ };
30
+ }>;
31
+ traceId?: string;
32
+ spanId?: string;
33
+ }
34
+ /**
35
+ * Convert an evlog WideEvent to an OTLP LogRecord.
36
+ */
37
+ declare function toOTLPLogRecord(event: WideEvent): OTLPLogRecord;
38
+ /**
39
+ * Create a drain function for sending logs to an OTLP endpoint.
40
+ *
41
+ * Configuration priority (highest to lowest):
42
+ * 1. Overrides passed to createOTLPDrain()
43
+ * 2. runtimeConfig.evlog.otlp (NUXT_EVLOG_OTLP_*)
44
+ * 3. runtimeConfig.otlp (NUXT_OTLP_*)
45
+ * 4. Environment variables: OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * // Zero config - reads from runtimeConfig or env vars
50
+ * nitroApp.hooks.hook('evlog:drain', createOTLPDrain())
51
+ *
52
+ * // With overrides
53
+ * nitroApp.hooks.hook('evlog:drain', createOTLPDrain({
54
+ * endpoint: 'http://localhost:4318',
55
+ * }))
56
+ * ```
57
+ */
58
+ declare function createOTLPDrain(overrides?: Partial<OTLPConfig>): (ctx: DrainContext) => Promise<void>;
59
+ /**
60
+ * Send a single event to an OTLP endpoint.
61
+ *
62
+ * @example
63
+ * ```ts
64
+ * await sendToOTLP(event, {
65
+ * endpoint: 'http://localhost:4318',
66
+ * })
67
+ * ```
68
+ */
69
+ declare function sendToOTLP(event: WideEvent, config: OTLPConfig): Promise<void>;
70
+ /**
71
+ * Send a batch of events to an OTLP endpoint.
72
+ *
73
+ * @example
74
+ * ```ts
75
+ * await sendBatchToOTLP(events, {
76
+ * endpoint: 'http://localhost:4318',
77
+ * })
78
+ * ```
79
+ */
80
+ declare function sendBatchToOTLP(events: WideEvent[], config: OTLPConfig): Promise<void>;
81
+
82
+ export { createOTLPDrain, sendBatchToOTLP, sendToOTLP, toOTLPLogRecord };
83
+ export type { OTLPConfig, OTLPLogRecord };
@@ -0,0 +1,202 @@
1
+ const SEVERITY_MAP = {
2
+ debug: 5,
3
+ // DEBUG
4
+ info: 9,
5
+ // INFO
6
+ warn: 13,
7
+ // WARN
8
+ error: 17
9
+ // ERROR
10
+ };
11
+ const SEVERITY_TEXT_MAP = {
12
+ debug: "DEBUG",
13
+ info: "INFO",
14
+ warn: "WARN",
15
+ error: "ERROR"
16
+ };
17
+ function getRuntimeConfig() {
18
+ try {
19
+ const { useRuntimeConfig } = require("nitropack/runtime");
20
+ return useRuntimeConfig();
21
+ } catch {
22
+ return void 0;
23
+ }
24
+ }
25
+ function toAttributeValue(value) {
26
+ if (typeof value === "boolean") {
27
+ return { boolValue: value };
28
+ }
29
+ if (typeof value === "number" && Number.isInteger(value)) {
30
+ return { intValue: String(value) };
31
+ }
32
+ if (typeof value === "string") {
33
+ return { stringValue: value };
34
+ }
35
+ return { stringValue: JSON.stringify(value) };
36
+ }
37
+ function toOTLPLogRecord(event) {
38
+ const timestamp = new Date(event.timestamp).getTime() * 1e6;
39
+ const { level, traceId, spanId, ...rest } = event;
40
+ delete rest.timestamp;
41
+ delete rest.service;
42
+ delete rest.environment;
43
+ delete rest.version;
44
+ delete rest.commitHash;
45
+ delete rest.region;
46
+ const attributes = [];
47
+ for (const [key, value] of Object.entries(rest)) {
48
+ if (value !== void 0 && value !== null) {
49
+ attributes.push({
50
+ key,
51
+ value: toAttributeValue(value)
52
+ });
53
+ }
54
+ }
55
+ const record = {
56
+ timeUnixNano: String(timestamp),
57
+ severityNumber: SEVERITY_MAP[level] ?? 9,
58
+ severityText: SEVERITY_TEXT_MAP[level] ?? "INFO",
59
+ body: { stringValue: JSON.stringify(event) },
60
+ attributes
61
+ };
62
+ if (typeof traceId === "string") {
63
+ record.traceId = traceId;
64
+ }
65
+ if (typeof spanId === "string") {
66
+ record.spanId = spanId;
67
+ }
68
+ return record;
69
+ }
70
+ function buildResourceAttributes(event, config) {
71
+ const attributes = [];
72
+ attributes.push({
73
+ key: "service.name",
74
+ value: { stringValue: config.serviceName ?? event.service }
75
+ });
76
+ if (event.environment) {
77
+ attributes.push({
78
+ key: "deployment.environment",
79
+ value: { stringValue: event.environment }
80
+ });
81
+ }
82
+ if (event.version) {
83
+ attributes.push({
84
+ key: "service.version",
85
+ value: { stringValue: event.version }
86
+ });
87
+ }
88
+ if (event.region) {
89
+ attributes.push({
90
+ key: "cloud.region",
91
+ value: { stringValue: event.region }
92
+ });
93
+ }
94
+ if (event.commitHash) {
95
+ attributes.push({
96
+ key: "vcs.commit.id",
97
+ value: { stringValue: event.commitHash }
98
+ });
99
+ }
100
+ if (config.resourceAttributes) {
101
+ for (const [key, value] of Object.entries(config.resourceAttributes)) {
102
+ attributes.push({
103
+ key,
104
+ value: toAttributeValue(value)
105
+ });
106
+ }
107
+ }
108
+ return attributes;
109
+ }
110
+ function createOTLPDrain(overrides) {
111
+ return async (ctx) => {
112
+ const runtimeConfig = getRuntimeConfig();
113
+ const evlogOtlp = runtimeConfig?.evlog?.otlp;
114
+ const rootOtlp = runtimeConfig?.otlp;
115
+ const getHeadersFromEnv = () => {
116
+ const headersEnv = process.env.OTEL_EXPORTER_OTLP_HEADERS || process.env.NUXT_OTLP_HEADERS;
117
+ if (headersEnv) {
118
+ const headers = {};
119
+ const decoded = decodeURIComponent(headersEnv);
120
+ for (const pair of decoded.split(",")) {
121
+ const eqIndex = pair.indexOf("=");
122
+ if (eqIndex > 0) {
123
+ const key = pair.slice(0, eqIndex).trim();
124
+ const value = pair.slice(eqIndex + 1).trim();
125
+ if (key && value) {
126
+ headers[key] = value;
127
+ }
128
+ }
129
+ }
130
+ if (Object.keys(headers).length > 0) return headers;
131
+ }
132
+ const auth = process.env.NUXT_OTLP_AUTH;
133
+ if (auth) {
134
+ return { Authorization: auth };
135
+ }
136
+ return void 0;
137
+ };
138
+ const config = {
139
+ endpoint: overrides?.endpoint ?? evlogOtlp?.endpoint ?? rootOtlp?.endpoint ?? process.env.NUXT_OTLP_ENDPOINT ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
140
+ serviceName: overrides?.serviceName ?? evlogOtlp?.serviceName ?? rootOtlp?.serviceName ?? process.env.NUXT_OTLP_SERVICE_NAME ?? process.env.OTEL_SERVICE_NAME,
141
+ headers: overrides?.headers ?? evlogOtlp?.headers ?? rootOtlp?.headers ?? getHeadersFromEnv(),
142
+ resourceAttributes: overrides?.resourceAttributes ?? evlogOtlp?.resourceAttributes ?? rootOtlp?.resourceAttributes,
143
+ timeout: overrides?.timeout ?? evlogOtlp?.timeout ?? rootOtlp?.timeout
144
+ };
145
+ if (!config.endpoint) {
146
+ console.error("[evlog/otlp] Missing endpoint. Set NUXT_OTLP_ENDPOINT or OTEL_EXPORTER_OTLP_ENDPOINT env var, or pass to createOTLPDrain()");
147
+ return;
148
+ }
149
+ try {
150
+ await sendToOTLP(ctx.event, config);
151
+ } catch (error) {
152
+ console.error("[evlog/otlp] Failed to send event:", error);
153
+ }
154
+ };
155
+ }
156
+ async function sendToOTLP(event, config) {
157
+ await sendBatchToOTLP([event], config);
158
+ }
159
+ async function sendBatchToOTLP(events, config) {
160
+ if (events.length === 0) return;
161
+ const timeout = config.timeout ?? 5e3;
162
+ const url = `${config.endpoint.replace(/\/$/, "")}/v1/logs`;
163
+ const [firstEvent] = events;
164
+ const resourceAttributes = buildResourceAttributes(firstEvent, config);
165
+ const logRecords = events.map(toOTLPLogRecord);
166
+ const payload = {
167
+ resourceLogs: [
168
+ {
169
+ resource: { attributes: resourceAttributes },
170
+ scopeLogs: [
171
+ {
172
+ scope: { name: "evlog", version: "1.0.0" },
173
+ logRecords
174
+ }
175
+ ]
176
+ }
177
+ ]
178
+ };
179
+ const headers = {
180
+ "Content-Type": "application/json",
181
+ ...config.headers
182
+ };
183
+ const controller = new AbortController();
184
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
185
+ try {
186
+ const response = await fetch(url, {
187
+ method: "POST",
188
+ headers,
189
+ body: JSON.stringify(payload),
190
+ signal: controller.signal
191
+ });
192
+ if (!response.ok) {
193
+ const text = await response.text().catch(() => "Unknown error");
194
+ const safeText = text.length > 200 ? `${text.slice(0, 200)}...[truncated]` : text;
195
+ throw new Error(`OTLP API error: ${response.status} ${response.statusText} - ${safeText}`);
196
+ }
197
+ } finally {
198
+ clearTimeout(timeoutId);
199
+ }
200
+ }
201
+
202
+ export { createOTLPDrain, sendBatchToOTLP, sendToOTLP, toOTLPLogRecord };
package/dist/logger.mjs CHANGED
@@ -7,6 +7,7 @@ let globalEnv = {
7
7
  };
8
8
  let globalPretty = isDev();
9
9
  let globalSampling = {};
10
+ let globalStringify = true;
10
11
  function initLogger(config = {}) {
11
12
  const detected = detectEnvironment();
12
13
  globalEnv = {
@@ -18,6 +19,7 @@ function initLogger(config = {}) {
18
19
  };
19
20
  globalPretty = config.pretty ?? isDev();
20
21
  globalSampling = config.sampling ?? {};
22
+ globalStringify = config.stringify ?? true;
21
23
  }
22
24
  function shouldSample(level) {
23
25
  const { rates } = globalSampling;
@@ -57,8 +59,10 @@ function emitWideEvent(level, event, skipSamplingCheck = false) {
57
59
  };
58
60
  if (globalPretty) {
59
61
  prettyPrintWideEvent(formatted);
60
- } else {
62
+ } else if (globalStringify) {
61
63
  console[getConsoleMethod(level)](JSON.stringify(formatted));
64
+ } else {
65
+ console[getConsoleMethod(level)](formatted);
62
66
  }
63
67
  return formatted;
64
68
  }
@@ -70,9 +74,9 @@ function emitTaggedLog(level, tag, message) {
70
74
  const color = getLevelColor(level);
71
75
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().slice(11, 23);
72
76
  console.log(`${colors.dim}${timestamp}${colors.reset} ${color}[${tag}]${colors.reset} ${message}`);
73
- } else {
74
- emitWideEvent(level, { tag, message });
77
+ return;
75
78
  }
79
+ emitWideEvent(level, { tag, message });
76
80
  }
77
81
  function formatValue(value) {
78
82
  if (value === null || value === void 0) {
@@ -54,6 +54,56 @@ interface ModuleOptions {
54
54
  * ```
55
55
  */
56
56
  transport?: TransportConfig;
57
+ /**
58
+ * Axiom adapter configuration.
59
+ * When configured, use `createAxiomDrain()` from `evlog/axiom` to send logs.
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * axiom: {
64
+ * dataset: 'my-app-logs',
65
+ * token: process.env.AXIOM_TOKEN,
66
+ * }
67
+ * ```
68
+ */
69
+ axiom?: {
70
+ /** Axiom dataset name */
71
+ dataset: string;
72
+ /** Axiom API token */
73
+ token: string;
74
+ /** Organization ID (required for Personal Access Tokens) */
75
+ orgId?: string;
76
+ /** Base URL for Axiom API. Default: https://api.axiom.co */
77
+ baseUrl?: string;
78
+ /** Request timeout in milliseconds. Default: 5000 */
79
+ timeout?: number;
80
+ };
81
+ /**
82
+ * OTLP adapter configuration.
83
+ * When configured, use `createOTLPDrain()` from `evlog/otlp` to send logs.
84
+ *
85
+ * @example
86
+ * ```ts
87
+ * otlp: {
88
+ * endpoint: 'http://localhost:4318',
89
+ * headers: {
90
+ * 'Authorization': `Basic ${process.env.GRAFANA_TOKEN}`,
91
+ * },
92
+ * }
93
+ * ```
94
+ */
95
+ otlp?: {
96
+ /** OTLP HTTP endpoint (e.g., http://localhost:4318) */
97
+ endpoint: string;
98
+ /** Override service name (defaults to event.service) */
99
+ serviceName?: string;
100
+ /** Additional resource attributes */
101
+ resourceAttributes?: Record<string, string | number | boolean>;
102
+ /** Custom headers (e.g., for authentication) */
103
+ headers?: Record<string, string>;
104
+ /** Request timeout in milliseconds. Default: 5000 */
105
+ timeout?: number;
106
+ };
57
107
  }
58
108
  declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
59
109
 
@@ -54,6 +54,56 @@ interface ModuleOptions {
54
54
  * ```
55
55
  */
56
56
  transport?: TransportConfig;
57
+ /**
58
+ * Axiom adapter configuration.
59
+ * When configured, use `createAxiomDrain()` from `evlog/axiom` to send logs.
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * axiom: {
64
+ * dataset: 'my-app-logs',
65
+ * token: process.env.AXIOM_TOKEN,
66
+ * }
67
+ * ```
68
+ */
69
+ axiom?: {
70
+ /** Axiom dataset name */
71
+ dataset: string;
72
+ /** Axiom API token */
73
+ token: string;
74
+ /** Organization ID (required for Personal Access Tokens) */
75
+ orgId?: string;
76
+ /** Base URL for Axiom API. Default: https://api.axiom.co */
77
+ baseUrl?: string;
78
+ /** Request timeout in milliseconds. Default: 5000 */
79
+ timeout?: number;
80
+ };
81
+ /**
82
+ * OTLP adapter configuration.
83
+ * When configured, use `createOTLPDrain()` from `evlog/otlp` to send logs.
84
+ *
85
+ * @example
86
+ * ```ts
87
+ * otlp: {
88
+ * endpoint: 'http://localhost:4318',
89
+ * headers: {
90
+ * 'Authorization': `Basic ${process.env.GRAFANA_TOKEN}`,
91
+ * },
92
+ * }
93
+ * ```
94
+ */
95
+ otlp?: {
96
+ /** OTLP HTTP endpoint (e.g., http://localhost:4318) */
97
+ endpoint: string;
98
+ /** Override service name (defaults to event.service) */
99
+ serviceName?: string;
100
+ /** Additional resource attributes */
101
+ resourceAttributes?: Record<string, string | number | boolean>;
102
+ /** Custom headers (e.g., for authentication) */
103
+ headers?: Record<string, string>;
104
+ /** Request timeout in milliseconds. Default: 5000 */
105
+ timeout?: number;
106
+ };
57
107
  }
58
108
  declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
59
109
 
package/dist/types.d.mts CHANGED
@@ -185,6 +185,12 @@ interface LoggerConfig {
185
185
  pretty?: boolean;
186
186
  /** Sampling configuration for filtering logs */
187
187
  sampling?: SamplingConfig;
188
+ /**
189
+ * When pretty is disabled, emit JSON strings (default) or raw objects.
190
+ * Set to false for environments like Cloudflare Workers that expect objects.
191
+ * @default true
192
+ */
193
+ stringify?: boolean;
188
194
  }
189
195
  /**
190
196
  * Base structure for all wide events
package/dist/types.d.ts CHANGED
@@ -185,6 +185,12 @@ interface LoggerConfig {
185
185
  pretty?: boolean;
186
186
  /** Sampling configuration for filtering logs */
187
187
  sampling?: SamplingConfig;
188
+ /**
189
+ * When pretty is disabled, emit JSON strings (default) or raw objects.
190
+ * Set to false for environments like Cloudflare Workers that expect objects.
191
+ * @default true
192
+ */
193
+ stringify?: boolean;
188
194
  }
189
195
  /**
190
196
  * Base structure for all wide events
package/dist/utils.d.mts CHANGED
@@ -5,7 +5,7 @@ declare function isServer(): boolean;
5
5
  declare function isClient(): boolean;
6
6
  declare function isDev(): boolean;
7
7
  declare function detectEnvironment(): Partial<EnvironmentContext>;
8
- declare function getConsoleMethod(level: LogLevel): 'log' | 'error' | 'warn';
8
+ declare function getConsoleMethod(level: LogLevel): LogLevel;
9
9
  declare const colors: {
10
10
  readonly reset: "\u001B[0m";
11
11
  readonly bold: "\u001B[1m";
package/dist/utils.d.ts CHANGED
@@ -5,7 +5,7 @@ declare function isServer(): boolean;
5
5
  declare function isClient(): boolean;
6
6
  declare function isDev(): boolean;
7
7
  declare function detectEnvironment(): Partial<EnvironmentContext>;
8
- declare function getConsoleMethod(level: LogLevel): 'log' | 'error' | 'warn';
8
+ declare function getConsoleMethod(level: LogLevel): LogLevel;
9
9
  declare const colors: {
10
10
  readonly reset: "\u001B[0m";
11
11
  readonly bold: "\u001B[1m";
package/dist/utils.mjs CHANGED
@@ -11,15 +11,19 @@ function isClient() {
11
11
  return typeof window !== "undefined";
12
12
  }
13
13
  function isDev() {
14
- if (typeof process !== "undefined" && process.env.NODE_ENV) {
14
+ if (typeof process !== "undefined") {
15
15
  return process.env.NODE_ENV !== "production";
16
16
  }
17
- return true;
17
+ if (typeof window !== "undefined") {
18
+ return true;
19
+ }
20
+ return false;
18
21
  }
19
22
  function detectEnvironment() {
20
23
  const env = typeof process !== "undefined" ? process.env : {};
24
+ const defaultEnvironment = isDev() ? "development" : "production";
21
25
  return {
22
- environment: env.NODE_ENV || "development",
26
+ environment: env.NODE_ENV || defaultEnvironment,
23
27
  service: env.SERVICE_NAME || "app",
24
28
  version: env.APP_VERSION,
25
29
  commitHash: env.COMMIT_SHA || env.GITHUB_SHA || env.VERCEL_GIT_COMMIT_SHA || env.CF_PAGES_COMMIT_SHA,
@@ -27,7 +31,7 @@ function detectEnvironment() {
27
31
  };
28
32
  }
29
33
  function getConsoleMethod(level) {
30
- return level === "error" ? "error" : level === "warn" ? "warn" : "log";
34
+ return level;
31
35
  }
32
36
  const colors = {
33
37
  reset: "\x1B[0m",
@@ -0,0 +1,45 @@
1
+ import { RequestLogger, LoggerConfig } from './types.mjs';
2
+
3
+ /**
4
+ * Options for createWorkersLogger
5
+ */
6
+ interface WorkersLoggerOptions {
7
+ /** Override the request ID (default: cf-ray header) */
8
+ requestId?: string;
9
+ /** Headers to include in logs (default: none) */
10
+ headers?: string[];
11
+ }
12
+ /**
13
+ * Initialize evlog for Cloudflare Workers.
14
+ * Call once at module scope.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * initWorkersLogger({
19
+ * env: { service: 'my-api' },
20
+ * })
21
+ * ```
22
+ */
23
+ declare function initWorkersLogger(options?: LoggerConfig): void;
24
+ /**
25
+ * Create a request-scoped logger for Cloudflare Workers.
26
+ * Auto-extracts cf-ray, request.cf context, method, and path.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * export default {
31
+ * async fetch(request: Request) {
32
+ * const log = createWorkersLogger(request)
33
+ *
34
+ * log.set({ user: { id: '123' } })
35
+ * log.emit({ status: 200 })
36
+ *
37
+ * return new Response('ok')
38
+ * }
39
+ * }
40
+ * ```
41
+ */
42
+ declare function createWorkersLogger(request: Request, options?: WorkersLoggerOptions): RequestLogger;
43
+
44
+ export { createWorkersLogger, initWorkersLogger };
45
+ export type { WorkersLoggerOptions };
@@ -0,0 +1,45 @@
1
+ import { RequestLogger, LoggerConfig } from './types.js';
2
+
3
+ /**
4
+ * Options for createWorkersLogger
5
+ */
6
+ interface WorkersLoggerOptions {
7
+ /** Override the request ID (default: cf-ray header) */
8
+ requestId?: string;
9
+ /** Headers to include in logs (default: none) */
10
+ headers?: string[];
11
+ }
12
+ /**
13
+ * Initialize evlog for Cloudflare Workers.
14
+ * Call once at module scope.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * initWorkersLogger({
19
+ * env: { service: 'my-api' },
20
+ * })
21
+ * ```
22
+ */
23
+ declare function initWorkersLogger(options?: LoggerConfig): void;
24
+ /**
25
+ * Create a request-scoped logger for Cloudflare Workers.
26
+ * Auto-extracts cf-ray, request.cf context, method, and path.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * export default {
31
+ * async fetch(request: Request) {
32
+ * const log = createWorkersLogger(request)
33
+ *
34
+ * log.set({ user: { id: '123' } })
35
+ * log.emit({ status: 200 })
36
+ *
37
+ * return new Response('ok')
38
+ * }
39
+ * }
40
+ * ```
41
+ */
42
+ declare function createWorkersLogger(request: Request, options?: WorkersLoggerOptions): RequestLogger;
43
+
44
+ export { createWorkersLogger, initWorkersLogger };
45
+ export type { WorkersLoggerOptions };
@@ -0,0 +1,53 @@
1
+ import { createRequestLogger, initLogger } from './logger.mjs';
2
+ import 'defu';
3
+ import './utils.mjs';
4
+
5
+ function isRecord(value) {
6
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7
+ }
8
+ function collectHeaders(headers, include) {
9
+ if (!include || include.length === 0) return void 0;
10
+ const normalized = new Set(include.map((h) => h.toLowerCase()));
11
+ const result = {};
12
+ headers.forEach((value, key) => {
13
+ if (normalized.has(key.toLowerCase())) {
14
+ result[key] = value;
15
+ }
16
+ });
17
+ return Object.keys(result).length > 0 ? result : void 0;
18
+ }
19
+ function initWorkersLogger(options = {}) {
20
+ initLogger({
21
+ ...options,
22
+ pretty: false,
23
+ stringify: false
24
+ });
25
+ }
26
+ function pickCfContext(request) {
27
+ const cf = Reflect.get(request, "cf");
28
+ if (!isRecord(cf)) return {};
29
+ const out = {};
30
+ if (typeof cf.colo === "string") out.colo = cf.colo;
31
+ if (typeof cf.country === "string") out.country = cf.country;
32
+ if (typeof cf.asn === "number") out.asn = cf.asn;
33
+ return out;
34
+ }
35
+ function createWorkersLogger(request, options = {}) {
36
+ const url = new URL(request.url);
37
+ const cfRay = request.headers.get("cf-ray") ?? void 0;
38
+ const traceparent = request.headers.get("traceparent") ?? void 0;
39
+ const log = createRequestLogger({
40
+ method: request.method,
41
+ path: url.pathname,
42
+ requestId: options.requestId ?? cfRay
43
+ });
44
+ log.set({
45
+ cfRay,
46
+ traceparent,
47
+ ...pickCfContext(request),
48
+ ...options.headers ? { requestHeaders: collectHeaders(request.headers, options.headers) } : {}
49
+ });
50
+ return log;
51
+ }
52
+
53
+ export { createWorkersLogger, initWorkersLogger };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evlog",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Wide event logging library with structured error handling. Inspired by LoggingSucks.",
5
5
  "author": "HugoRCD <contact@hrcd.fr>",
6
6
  "homepage": "https://evlog.dev",
@@ -34,6 +34,18 @@
34
34
  "./nitro": {
35
35
  "types": "./dist/nitro/plugin.d.mts",
36
36
  "import": "./dist/nitro/plugin.mjs"
37
+ },
38
+ "./workers": {
39
+ "types": "./dist/workers.d.mts",
40
+ "import": "./dist/workers.mjs"
41
+ },
42
+ "./axiom": {
43
+ "types": "./dist/adapters/axiom.d.mts",
44
+ "import": "./dist/adapters/axiom.mjs"
45
+ },
46
+ "./otlp": {
47
+ "types": "./dist/adapters/otlp.d.mts",
48
+ "import": "./dist/adapters/otlp.mjs"
37
49
  }
38
50
  },
39
51
  "main": "./dist/index.mjs",
@@ -48,6 +60,15 @@
48
60
  ],
49
61
  "nitro": [
50
62
  "./dist/nitro/plugin.d.mts"
63
+ ],
64
+ "workers": [
65
+ "./dist/workers.d.mts"
66
+ ],
67
+ "axiom": [
68
+ "./dist/adapters/axiom.d.mts"
69
+ ],
70
+ "otlp": [
71
+ "./dist/adapters/otlp.d.mts"
51
72
  ]
52
73
  }
53
74
  },