1/**
2 * Exports Effect log entries over OTLP/HTTP.
3 *
4 * The logger turns Effect log entries into OTLP log records and sends them to a
5 * logs endpoint, such as an OpenTelemetry Collector or vendor OTLP endpoint. It
6 * includes log levels, messages, annotations, causes, fiber ids, optional log
7 * spans, and current trace/span ids when they are present.
8 *
9 * @stability unstable
10 * @since 4.0.0
11 */
12import * as Arr from "../Array.ts"
13import * as Cause from "../Cause.ts"
14import { Clock } from "../Clock.ts"
15import * as Config from "../Config.ts"
16import * as Duration from "../Duration.ts"
17import * as Effect from "../Effect.ts"
18import type * as Headers from "../http/Headers.ts"
19import type * as HttpClient from "../http/HttpClient.ts"
20import * as Layer from "../Layer.ts"
21import * as Logger from "../Logger.ts"
22import type * as LogLevel from "../LogLevel.ts"
23import * as Option from "../Option.ts"
24import { CurrentLogAnnotations, CurrentLogSpans } from "../References.ts"
25import type * as Scope from "../Scope.ts"
26import * as OtlpEnv from "./internal/otlpEnv.ts"
27import * as Exporter from "./OtlpExporter.ts"
28import type { AnyValue, Fixed64, KeyValue, Resource } from "./OtlpResource.ts"
29import * as OtlpResource from "./OtlpResource.ts"
30import { OtlpSerialization } from "./OtlpSerialization.ts"
31
32/**
33 * Creates an Effect `Logger` that exports log records through OTLP.
34 *
35 * **Details**
36 *
37 * The logger serializes records with the configured resource, sends them
38 * through the OTLP exporter, and requires `Scope` so pending records can be
39 * flushed on shutdown.
40 *
41 * @stability unstable
42 * @category constructors
43 * @since 4.0.0
44 */
45export const make: (
46 options: {
47 readonly url: string
48 readonly resource?: {
49 readonly serviceName?: string | undefined
50 readonly serviceVersion?: string | undefined
51 readonly attributes?: Record<string, unknown>
52 } | undefined
53 readonly headers?: Headers.Input | undefined
54 readonly exportInterval?: Duration.Input | undefined
55 readonly maxBatchSize?: number | undefined
56 readonly shutdownTimeout?: Duration.Input | undefined
57 readonly excludeLogSpans?: boolean | undefined
58 }
59) => Effect.Effect<
60 Logger.Logger<unknown, void>,
61 never,
62 Exporter.Flusher | OtlpSerialization | HttpClient.HttpClient | Scope.Scope
63> = Effect.fnUntraced(function*(options) {
64 const serialization = yield* OtlpSerialization
65 const otelResource = yield* OtlpResource.fromConfig(options.resource)
66 const scope: IInstrumentationScope = {
67 name: OtlpResource.serviceNameUnsafe(otelResource)
68 }
69
70 const exporter = yield* Exporter.make({
71 label: "OtlpLogger",
72 url: options.url,
73 headers: options.headers,
74 maxBatchSize: options.maxBatchSize ?? 1000,
75 exportInterval: options.exportInterval ?? Duration.seconds(1),
76 body: (data) => [
77 serialization.logs({
78 resourceLogs: [{
79 resource: otelResource,
80 scopeLogs: [{
81 scope,
82 logRecords: data
83 }]
84 }]
85 }),
86 Effect.void
87 ],
88 shutdownTimeout: options.shutdownTimeout ?? Duration.seconds(3)
89 })
90
91 const opts = {
92 excludeLogSpans: options.excludeLogSpans ?? false,
93 clock: yield* Clock
94 }
95 return Logger.make((options) => {
96 exporter.push(makeLogRecord(options, opts))
97 })
98})
99
100/**
101 * Layer that installs the OTLP logger created by `make`.
102 *
103 * **Details**
104 *
105 * By default the OTLP logger is merged with any existing loggers.
106 *
107 * @stability unstable
108 * @category layers
109 * @since 4.0.0
110 */
111export const layer = (options: {
112 readonly url: string
113 readonly resource?: {
114 readonly serviceName?: string | undefined
115 readonly serviceVersion?: string | undefined
116 readonly attributes?: Record<string, unknown>
117 } | undefined
118 readonly headers?: Headers.Input | undefined
119 readonly exportInterval?: Duration.Input | undefined
120 readonly maxBatchSize?: number | undefined
121 readonly shutdownTimeout?: Duration.Input | undefined
122 readonly excludeLogSpans?: boolean | undefined
123 readonly mergeWithExisting?: boolean | undefined
124}): Layer.Layer<Exporter.Flusher, never, HttpClient.HttpClient | OtlpSerialization> =>
125 Logger.layer([make(options)], {
126 mergeWithExisting: options.mergeWithExisting ?? true
127 }).pipe(Layer.provideMerge(Exporter.layerFlusher))
128
129/**
130 * Creates an OTLP logs layer from OpenTelemetry configuration.
131 *
132 * @stability unstable
133 * @category layers
134 * @since 4.0.0
135 */
136export const layerFromConfig = (options?: {
137 readonly resource?: {
138 readonly serviceName?: string | undefined
139 readonly serviceVersion?: string | undefined
140 readonly attributes?: Record<string, unknown>
141 } | undefined
142 readonly headers?: Headers.Input | undefined
143 readonly excludeLogSpans?: boolean | undefined
144 readonly mergeWithExisting?: boolean | undefined
145}): Layer.Layer<Exporter.Flusher, never, HttpClient.HttpClient | OtlpSerialization> =>
146 Effect.gen(function*() {
147 const { disabled, endpoint, exporters } = yield* Config.all({
148 disabled: Config.Boolean("OTEL_SDK_DISABLED").pipe(Config.withDefault(false)),
149 endpoint: OtlpEnv.endpoint("LOGS"),
150 exporters: OtlpEnv.exporters("LOGS")
151 })
152
153 if (disabled || !endpoint || !exporters.includes("otlp")) {
154 return Exporter.layerFlusher
155 }
156
157 const { baseTimeout, logsTimeout, exportTimeout, scheduleDelay, maxBatchSize } = yield* Config.all({
158 baseTimeout: Config.option(Config.Int("OTEL_EXPORTER_OTLP_TIMEOUT")),
159 logsTimeout: Config.option(Config.Int("OTEL_EXPORTER_OTLP_LOGS_TIMEOUT")),
160 exportTimeout: Config.option(Config.Int("OTEL_BLRP_EXPORT_TIMEOUT")),
161 scheduleDelay: Config.option(Config.Int("OTEL_BLRP_SCHEDULE_DELAY")),
162 maxBatchSize: Config.option(Config.Int("OTEL_BLRP_MAX_EXPORT_BATCH_SIZE"))
163 })
164
165 const shutdownTimeout = Option.firstSomeOf([logsTimeout, baseTimeout, exportTimeout]).pipe(
166 Option.map((_) => Duration.millis(_))
167 )
168 const exportInterval = Option.map(scheduleDelay, (_) => Duration.millis(_))
169
170 return layer({
171 url: endpoint.toString(),
172 resource: options?.resource,
173 headers: options?.headers ?? (yield* OtlpEnv.headers("LOGS")),
174 exportInterval: Option.getOrUndefined(exportInterval),
175 maxBatchSize: Option.getOrUndefined(maxBatchSize),
176 shutdownTimeout: Option.getOrUndefined(shutdownTimeout),
177 excludeLogSpans: options?.excludeLogSpans,
178 mergeWithExisting: options?.mergeWithExisting
179 })
180 }).pipe(Effect.orDie, Layer.unwrap)
181
182/**
183 * OTLP logs payload serialized by `OtlpLogger`.
184 *
185 * @stability unstable
186 * @category models
187 * @since 4.0.0
188 */
189export interface LogsData {
190 resourceLogs: ReadonlyArray<IResourceLogs>
191}
192
193// internal
194
195const makeLogRecord = (options: Logger.Options<unknown>, opts: {
196 readonly excludeLogSpans: boolean
197 readonly clock: Clock
198}): ILogRecord => {
199 const now = opts.clock.currentTimeNanosUnsafe()
200 const nanosString = now.toString()
201 const nowMillis = options.date.getTime()
202
203 const attributes = OtlpResource.entriesToAttributes(Object.entries(options.fiber.getRef(CurrentLogAnnotations)))
204 attributes.push({
205 key: "fiberId",
206 value: { intValue: options.fiber.id }
207 })
208 if (!opts.excludeLogSpans) {
209 for (const [label, startTime] of options.fiber.getRef(CurrentLogSpans)) {
210 attributes.push({
211 key: `logSpan.${label}`,
212 value: { stringValue: `${nowMillis - startTime}ms` }
213 })
214 }
215 }
216 if (options.cause.reasons.length > 0) {
217 attributes.push({
218 key: "log.error",
219 value: { stringValue: Cause.pretty(options.cause) }
220 })
221 }
222
223 const message = Arr.ensure(options.message)
224
225 const logRecord: ILogRecord = {
226 severityNumber: logLevelToSeverityNumber(options.logLevel),
227 severityText: options.logLevel,
228 timeUnixNano: nanosString,
229 observedTimeUnixNano: nanosString,
230 attributes,
231 body: OtlpResource.unknownToAttributeValue(message.length === 1 ? message[0] : message),
232 droppedAttributesCount: 0
233 }
234
235 if (options.fiber.cache.span) {
236 logRecord.traceId = options.fiber.cache.span.traceId
237 logRecord.spanId = options.fiber.cache.span.spanId
238 }
239
240 return logRecord
241}
242
243/** Properties of an InstrumentationScope. */
244interface IInstrumentationScope {
245 /** InstrumentationScope name */
246 name: string
247 /** InstrumentationScope version */
248 version?: string
249 /** InstrumentationScope attributes */
250 attributes?: Array<KeyValue>
251 /** InstrumentationScope droppedAttributesCount */
252 droppedAttributesCount?: number
253}
254/** Properties of a ResourceLogs. */
255interface IResourceLogs {
256 /** ResourceLogs resource */
257 resource?: Resource
258 /** ResourceLogs scopeLogs */
259 scopeLogs: Array<IScopeLogs>
260 /** ResourceLogs schemaUrl */
261 schemaUrl?: string
262}
263/** Properties of an ScopeLogs. */
264interface IScopeLogs {
265 /** IScopeLogs scope */
266 scope?: IInstrumentationScope
267 /** IScopeLogs logRecords */
268 logRecords?: Array<ILogRecord>
269 /** IScopeLogs schemaUrl */
270 schemaUrl?: string | null
271}
272/** Properties of a LogRecord. */
273interface ILogRecord {
274 /** LogRecord timeUnixNano */
275 timeUnixNano: Fixed64
276 /** LogRecord observedTimeUnixNano */
277 observedTimeUnixNano: Fixed64
278 /** LogRecord severityNumber */
279 severityNumber?: ESeverityNumber
280 /** LogRecord severityText */
281 severityText?: string
282 /** LogRecord body */
283 body?: AnyValue
284 /** LogRecord attributes */
285 attributes: Array<KeyValue>
286 /** LogRecord droppedAttributesCount */
287 droppedAttributesCount: number
288 /** LogRecord flags */
289 flags?: number
290 /** LogRecord traceId */
291 traceId?: string | Uint8Array
292 /** LogRecord spanId */
293 spanId?: string | Uint8Array
294}
295
296const logLevelToSeverityNumber = (logLevel: LogLevel.LogLevel): ESeverityNumber => {
297 switch (logLevel) {
298 case "Trace":
299 return ESeverityNumber.SEVERITY_NUMBER_TRACE
300 case "Debug":
301 return ESeverityNumber.SEVERITY_NUMBER_DEBUG
302 case "Info":
303 return ESeverityNumber.SEVERITY_NUMBER_INFO
304 case "Warn":
305 return ESeverityNumber.SEVERITY_NUMBER_WARN
306 case "Error":
307 return ESeverityNumber.SEVERITY_NUMBER_ERROR
308 case "Fatal":
309 return ESeverityNumber.SEVERITY_NUMBER_FATAL
310 default:
311 return ESeverityNumber.SEVERITY_NUMBER_UNSPECIFIED
312 }
313}
314
315/**
316 * Numerical value of the severity, normalized to values described in Log Data Model.
317 */
318const ESeverityNumber = {
319 /** Unspecified. Do NOT use as default */
320 SEVERITY_NUMBER_UNSPECIFIED: 0,
321 SEVERITY_NUMBER_TRACE: 1,
322 SEVERITY_NUMBER_TRACE2: 2,
323 SEVERITY_NUMBER_TRACE3: 3,
324 SEVERITY_NUMBER_TRACE4: 4,
325 SEVERITY_NUMBER_DEBUG: 5,
326 SEVERITY_NUMBER_DEBUG2: 6,
327 SEVERITY_NUMBER_DEBUG3: 7,
328 SEVERITY_NUMBER_DEBUG4: 8,
329 SEVERITY_NUMBER_INFO: 9,
330 SEVERITY_NUMBER_INFO2: 10,
331 SEVERITY_NUMBER_INFO3: 11,
332 SEVERITY_NUMBER_INFO4: 12,
333 SEVERITY_NUMBER_WARN: 13,
334 SEVERITY_NUMBER_WARN2: 14,
335 SEVERITY_NUMBER_WARN3: 15,
336 SEVERITY_NUMBER_WARN4: 16,
337 SEVERITY_NUMBER_ERROR: 17,
338 SEVERITY_NUMBER_ERROR2: 18,
339 SEVERITY_NUMBER_ERROR3: 19,
340 SEVERITY_NUMBER_ERROR4: 20,
341 SEVERITY_NUMBER_FATAL: 21,
342 SEVERITY_NUMBER_FATAL2: 22,
343 SEVERITY_NUMBER_FATAL3: 23,
344 SEVERITY_NUMBER_FATAL4: 24
345} as const
346
347type ESeverityNumber = typeof ESeverityNumber[keyof typeof ESeverityNumber]