@kabran-tecnologia/kabran-config 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,446 @@
1
+ /**
2
+ * Edge Functions Telemetry Module
3
+ *
4
+ * OpenTelemetry integration for serverless/edge functions (Supabase, Deno, Cloudflare).
5
+ * Uses SimpleSpanProcessor for immediate export before function termination.
6
+ *
7
+ * @module telemetry/edge
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * import { serve } from 'https://deno.land/std/http/server.ts'
12
+ * import { withTelemetry, traceSupabaseQuery } from '@kabran-tecnologia/kabran-config/telemetry/edge'
13
+ *
14
+ * serve(withTelemetry('my-function', async (req, span) => {
15
+ * const result = await traceSupabaseQuery('select', 'users', () =>
16
+ * supabase.from('users').select()
17
+ * )
18
+ * return new Response(JSON.stringify(result.data))
19
+ * }))
20
+ * ```
21
+ */
22
+
23
+ import { resolveConfig } from '../config/index.mjs'
24
+ import {
25
+ getTracesPath,
26
+ getExportTimeoutEdge,
27
+ getErrorResponseConfig,
28
+ DEFAULT_SERVICE_VERSION,
29
+ DEFAULT_TRACER_NAME_EDGE,
30
+ } from '../config/defaults.mjs'
31
+ import { recordError, setAttributes, generateInvocationId, safeWarn, safeLog } from '../shared/helpers.mjs'
32
+
33
+ // State
34
+ let provider = null
35
+ let initialized = false
36
+ let resolvedConfig = null
37
+
38
+ /**
39
+ * Initialize provider for edge function
40
+ *
41
+ * @param {string} serviceName - Service/function name
42
+ * @param {import('../shared/types').TelemetryConfig} [config] - Additional config
43
+ */
44
+ async function initProvider(serviceName, config = {}) {
45
+ if (initialized) return
46
+
47
+ // Get environment (Deno-style)
48
+ const env = typeof Deno !== 'undefined' ? {
49
+ OTEL_ENDPOINT: Deno.env.get('OTEL_ENDPOINT'),
50
+ SERVICE_VERSION: Deno.env.get('SERVICE_VERSION'),
51
+ ENVIRONMENT: Deno.env.get('ENVIRONMENT'),
52
+ OTEL_ENABLED: Deno.env.get('OTEL_ENABLED'),
53
+ } : typeof process !== 'undefined' ? process.env : {}
54
+
55
+ resolvedConfig = resolveConfig(
56
+ { serviceName, ...config },
57
+ env,
58
+ env.ENVIRONMENT || 'production'
59
+ )
60
+
61
+ // Default enabled for edge (production-like)
62
+ if (resolvedConfig.enabled === false && env.OTEL_ENABLED !== 'false') {
63
+ resolvedConfig.enabled = true
64
+ }
65
+
66
+ if (!resolvedConfig.enabled) return
67
+
68
+ try {
69
+ // Dynamic imports for Deno compatibility
70
+ const [
71
+ { trace, context, propagation, SpanStatusCode },
72
+ { NodeTracerProvider },
73
+ { SimpleSpanProcessor },
74
+ { OTLPTraceExporter },
75
+ { Resource },
76
+ {
77
+ SEMRESATTRS_SERVICE_NAME,
78
+ SEMRESATTRS_SERVICE_VERSION,
79
+ SEMRESATTRS_DEPLOYMENT_ENVIRONMENT,
80
+ },
81
+ { W3CTraceContextPropagator },
82
+ ] = await Promise.all([
83
+ import('@opentelemetry/api'),
84
+ import('@opentelemetry/sdk-trace-node'),
85
+ import('@opentelemetry/sdk-trace-base'),
86
+ import('@opentelemetry/exporter-trace-otlp-http'),
87
+ import('@opentelemetry/resources'),
88
+ import('@opentelemetry/semantic-conventions'),
89
+ import('@opentelemetry/core'),
90
+ ])
91
+
92
+ provider = new NodeTracerProvider({
93
+ resource: new Resource({
94
+ [SEMRESATTRS_SERVICE_NAME]: resolvedConfig.serviceName,
95
+ [SEMRESATTRS_SERVICE_VERSION]: resolvedConfig.serviceVersion,
96
+ [SEMRESATTRS_DEPLOYMENT_ENVIRONMENT]: resolvedConfig.environment,
97
+ 'service.namespace': resolvedConfig.namespace,
98
+ 'faas.name': serviceName,
99
+ 'faas.runtime': typeof Deno !== 'undefined' ? 'deno' : 'node',
100
+ ...resolvedConfig.resourceAttributes,
101
+ }),
102
+ })
103
+
104
+ const exporter = new OTLPTraceExporter({
105
+ url: `${resolvedConfig.endpoint}${getTracesPath()}`,
106
+ timeoutMillis: getExportTimeoutEdge(),
107
+ })
108
+
109
+ // Use SimpleSpanProcessor for serverless (immediate export)
110
+ provider.addSpanProcessor(new SimpleSpanProcessor(exporter))
111
+
112
+ // Configure W3C Trace Context propagation
113
+ propagation.setGlobalPropagator(new W3CTraceContextPropagator())
114
+
115
+ provider.register()
116
+ initialized = true
117
+
118
+ safeLog(`[Telemetry] Initialized: ${resolvedConfig.serviceName}@${resolvedConfig.serviceVersion}`)
119
+ } catch (error) {
120
+ safeWarn('[Telemetry] Failed to initialize:', error)
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Get a tracer instance
126
+ *
127
+ * @param {string} [name] - Tracer name
128
+ * @returns {import('@opentelemetry/api').Tracer}
129
+ */
130
+ export function getTracer(name) {
131
+ const { trace } = require('@opentelemetry/api')
132
+ return trace.getTracer(
133
+ name || resolvedConfig?.serviceName || DEFAULT_TRACER_NAME_EDGE,
134
+ resolvedConfig?.serviceVersion || DEFAULT_SERVICE_VERSION
135
+ )
136
+ }
137
+
138
+ /**
139
+ * Get the currently active span
140
+ *
141
+ * @returns {import('@opentelemetry/api').Span|undefined}
142
+ */
143
+ export function getCurrentSpan() {
144
+ const { trace } = require('@opentelemetry/api')
145
+ return trace.getActiveSpan()
146
+ }
147
+
148
+ /**
149
+ * Get trace ID from current span
150
+ *
151
+ * @returns {string|undefined}
152
+ */
153
+ export function getTraceId() {
154
+ return getCurrentSpan()?.spanContext().traceId
155
+ }
156
+
157
+ /**
158
+ * Extract trace context from incoming request headers
159
+ *
160
+ * @param {Headers} headers - Request headers
161
+ * @returns {import('@opentelemetry/api').Context}
162
+ */
163
+ export function extractContext(headers) {
164
+ const { context, propagation } = require('@opentelemetry/api')
165
+ const carrier = {}
166
+ headers.forEach((value, key) => {
167
+ carrier[key.toLowerCase()] = value
168
+ })
169
+ return propagation.extract(context.active(), carrier)
170
+ }
171
+
172
+ /**
173
+ * Inject trace context into outgoing request headers
174
+ *
175
+ * @param {Headers} headers - Headers to inject into
176
+ */
177
+ export function injectContext(headers) {
178
+ const { context, propagation } = require('@opentelemetry/api')
179
+ const carrier = {}
180
+ propagation.inject(context.active(), carrier)
181
+ Object.entries(carrier).forEach(([key, value]) => {
182
+ headers.set(key, value)
183
+ })
184
+ }
185
+
186
+ /**
187
+ * Create a span for tracking an operation
188
+ *
189
+ * @template T
190
+ * @param {string} name - Span name
191
+ * @param {(span: import('@opentelemetry/api').Span) => T} fn - Function to execute
192
+ * @param {Record<string, string|number|boolean>} [attributes] - Initial attributes
193
+ * @returns {T}
194
+ */
195
+ export function createSpan(name, fn, attributes) {
196
+ const tracer = getTracer()
197
+ const { SpanStatusCode } = require('@opentelemetry/api')
198
+
199
+ return tracer.startActiveSpan(name, (span) => {
200
+ if (attributes) {
201
+ setAttributes(span, attributes)
202
+ }
203
+
204
+ try {
205
+ const result = fn(span)
206
+
207
+ if (result instanceof Promise) {
208
+ return result
209
+ .then((value) => {
210
+ span.setStatus({ code: SpanStatusCode.OK })
211
+ span.end()
212
+ return value
213
+ })
214
+ .catch((error) => {
215
+ recordError(span, error, SpanStatusCode)
216
+ span.end()
217
+ throw error
218
+ })
219
+ }
220
+
221
+ span.setStatus({ code: SpanStatusCode.OK })
222
+ span.end()
223
+ return result
224
+ } catch (error) {
225
+ recordError(span, error, SpanStatusCode)
226
+ span.end()
227
+ throw error
228
+ }
229
+ })
230
+ }
231
+
232
+ /**
233
+ * Create an async span
234
+ *
235
+ * @template T
236
+ * @param {string} name - Span name
237
+ * @param {(span: import('@opentelemetry/api').Span) => Promise<T>} fn - Async function
238
+ * @param {Record<string, string|number|boolean>} [attributes] - Initial attributes
239
+ * @returns {Promise<T>}
240
+ */
241
+ export async function createAsyncSpan(name, fn, attributes) {
242
+ const tracer = getTracer()
243
+ const { trace, context, SpanStatusCode } = require('@opentelemetry/api')
244
+
245
+ const span = tracer.startSpan(name)
246
+
247
+ if (attributes) {
248
+ setAttributes(span, attributes)
249
+ }
250
+
251
+ try {
252
+ const result = await context.with(trace.setSpan(context.active(), span), () => fn(span))
253
+ span.setStatus({ code: SpanStatusCode.OK })
254
+ return result
255
+ } catch (error) {
256
+ recordError(span, error, SpanStatusCode)
257
+ throw error
258
+ } finally {
259
+ span.end()
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Wrap a request handler with automatic telemetry instrumentation
265
+ *
266
+ * @param {string} functionName - Function name (used as service name)
267
+ * @param {import('../shared/types').EdgeHandler} handler - Request handler
268
+ * @param {import('../shared/types').TelemetryConfig} [config] - Additional config
269
+ * @returns {(req: Request) => Promise<Response>}
270
+ */
271
+ export function withTelemetry(functionName, handler, config = {}) {
272
+ return async (req) => {
273
+ // Initialize provider
274
+ await initProvider(functionName, config)
275
+
276
+ const { trace, context, SpanStatusCode } = await import('@opentelemetry/api')
277
+ const {
278
+ SEMATTRS_HTTP_METHOD,
279
+ SEMATTRS_HTTP_URL,
280
+ SEMATTRS_HTTP_STATUS_CODE,
281
+ SEMATTRS_HTTP_USER_AGENT,
282
+ } = await import('@opentelemetry/semantic-conventions')
283
+
284
+ // Skip telemetry for OPTIONS (CORS preflight)
285
+ if (req.method === 'OPTIONS') {
286
+ const noopSpan = trace.getTracer(functionName).startSpan('cors-preflight')
287
+ try {
288
+ return await handler(req, noopSpan)
289
+ } finally {
290
+ noopSpan.end()
291
+ }
292
+ }
293
+
294
+ // Skip if disabled
295
+ if (!resolvedConfig?.enabled) {
296
+ const noopSpan = trace.getTracer(functionName).startSpan('noop')
297
+ try {
298
+ return await handler(req, noopSpan)
299
+ } finally {
300
+ noopSpan.end()
301
+ }
302
+ }
303
+
304
+ const tracer = getTracer(functionName)
305
+ const parentContext = extractContext(req.headers)
306
+
307
+ const span = tracer.startSpan(
308
+ `${functionName}.handler`,
309
+ {
310
+ attributes: {
311
+ [SEMATTRS_HTTP_METHOD]: req.method,
312
+ [SEMATTRS_HTTP_URL]: req.url,
313
+ [SEMATTRS_HTTP_USER_AGENT]: req.headers.get('user-agent') || 'unknown',
314
+ 'faas.trigger': 'http',
315
+ 'faas.invocation_id': generateInvocationId(),
316
+ },
317
+ },
318
+ parentContext
319
+ )
320
+
321
+ const startTime = Date.now()
322
+
323
+ try {
324
+ const response = await context.with(
325
+ trace.setSpan(parentContext, span),
326
+ () => handler(req, span)
327
+ )
328
+
329
+ span.setAttribute(SEMATTRS_HTTP_STATUS_CODE, response.status)
330
+ span.setAttribute('http.response_content_length', response.headers.get('content-length') || '0')
331
+ span.setAttribute('faas.duration_ms', Date.now() - startTime)
332
+
333
+ if (response.status >= 400) {
334
+ span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${response.status}` })
335
+ } else {
336
+ span.setStatus({ code: SpanStatusCode.OK })
337
+ }
338
+
339
+ return response
340
+ } catch (error) {
341
+ recordError(span, error, SpanStatusCode)
342
+ span.setAttribute('faas.duration_ms', Date.now() - startTime)
343
+
344
+ const errorConfig = getErrorResponseConfig()
345
+ return new Response(
346
+ JSON.stringify({
347
+ error: errorConfig.message,
348
+ code: errorConfig.code,
349
+ trace_id: span.spanContext().traceId,
350
+ }),
351
+ {
352
+ status: 500,
353
+ headers: { 'Content-Type': 'application/json' },
354
+ }
355
+ )
356
+ } finally {
357
+ span.end()
358
+
359
+ // Force flush for serverless
360
+ if (provider) {
361
+ try {
362
+ await provider.forceFlush()
363
+ } catch {
364
+ // Ignore flush errors
365
+ }
366
+ }
367
+ }
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Wrap a Supabase query with telemetry
373
+ *
374
+ * @template T
375
+ * @param {string} operation - Operation type (select, insert, update, delete)
376
+ * @param {string} table - Table name
377
+ * @param {() => Promise<{data: T, error: Error|null}>} fn - Query function
378
+ * @returns {Promise<{data: T, error: Error|null}>}
379
+ */
380
+ export async function traceSupabaseQuery(operation, table, fn) {
381
+ return createAsyncSpan(
382
+ `supabase.${operation}`,
383
+ async (span) => {
384
+ span.setAttribute('db.system', 'postgresql')
385
+ span.setAttribute('db.name', 'supabase')
386
+ span.setAttribute('db.operation', operation)
387
+ span.setAttribute('db.sql.table', table)
388
+
389
+ const result = await fn()
390
+
391
+ const { SpanStatusCode } = await import('@opentelemetry/api')
392
+
393
+ if (result.error) {
394
+ span.setStatus({ code: SpanStatusCode.ERROR, message: result.error.message })
395
+ span.setAttribute('db.error', result.error.message)
396
+ } else {
397
+ span.setStatus({ code: SpanStatusCode.OK })
398
+ if (Array.isArray(result.data)) {
399
+ span.setAttribute('db.result_count', result.data.length)
400
+ }
401
+ }
402
+
403
+ return result
404
+ },
405
+ { 'db.system': 'postgresql' }
406
+ )
407
+ }
408
+
409
+ /**
410
+ * Add an event to the current span
411
+ *
412
+ * @param {string} name - Event name
413
+ * @param {Record<string, string|number|boolean>} [attributes] - Event attributes
414
+ */
415
+ export function addSpanEvent(name, attributes) {
416
+ const span = getCurrentSpan()
417
+ span?.addEvent(name, attributes)
418
+ }
419
+
420
+ /**
421
+ * Set attributes on the current span
422
+ *
423
+ * @param {Record<string, string|number|boolean>} attributes - Attributes to set
424
+ */
425
+ export function setSpanAttributes(attributes) {
426
+ const span = getCurrentSpan()
427
+ if (span) {
428
+ setAttributes(span, attributes)
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Shutdown telemetry provider
434
+ *
435
+ * @returns {Promise<void>}
436
+ */
437
+ export async function shutdownTelemetry() {
438
+ if (provider) {
439
+ await provider.shutdown()
440
+ initialized = false
441
+ provider = null
442
+ }
443
+ }
444
+
445
+ // Re-exports
446
+ export { resolveConfig } from '../config/index.mjs'