Skip to content

Commit 2c0b33b

Browse files
committed
feat: add otel integration
1 parent 4dcd549 commit 2c0b33b

File tree

12 files changed

+308
-11
lines changed

12 files changed

+308
-11
lines changed

package-lock.json

Lines changed: 38 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@athenna/logger",
3-
"version": "5.15.0",
3+
"version": "5.16.0",
44
"description": "The Athenna logging solution. Log in stdout, files and buckets.",
55
"license": "MIT",
66
"author": "João Lenon <lenon@athenna.io>",
@@ -63,6 +63,8 @@
6363
},
6464
"dependencies": {
6565
"@aws-lambda-powertools/logger": "^1.18.1",
66+
"@opentelemetry/api": "^1.9.0",
67+
"@opentelemetry/api-logs": "^0.213.0",
6668
"cls-rtracer": "^2.6.3",
6769
"telegraf": "^4.16.3"
6870
},
@@ -72,6 +74,7 @@
7274
"@athenna/ioc": "^5.2.0",
7375
"@athenna/test": "^5.5.0",
7476
"@athenna/tsconfig": "^5.0.0",
77+
"@opentelemetry/context-async-hooks": "^2.6.0",
7578
"@typescript-eslint/eslint-plugin": "^8.38.0",
7679
"@typescript-eslint/parser": "^8.38.0",
7780
"commitizen": "^4.3.1",

src/drivers/OtelDriver.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* @athenna/logger
3+
*
4+
* (c) João Lenon <lenon@athenna.io>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
import { Driver } from '#src/drivers/Driver'
11+
import { context } from '@opentelemetry/api'
12+
import { logs, SeverityNumber } from '@opentelemetry/api-logs'
13+
14+
export class OtelDriver extends Driver {
15+
public transport(level: string, message: any): void {
16+
if (!this.couldBeTransported(level)) {
17+
return
18+
}
19+
20+
const logger = logs.getLogger('@athenna/logger')
21+
22+
logger.emit({
23+
eventName: 'athenna.log',
24+
severityNumber: this.getSeverityNumber(level),
25+
severityText: level.toUpperCase(),
26+
body: this.getBody(level, message),
27+
attributes: {
28+
'athenna.log.level': level,
29+
'athenna.log.stream': this.getStreamTypeFor(level)
30+
},
31+
context: context.active()
32+
})
33+
}
34+
35+
private getBody(level: string, message: any) {
36+
const formatted = this.format(level, message, true)
37+
38+
try {
39+
return JSON.parse(formatted)
40+
} catch {
41+
return formatted
42+
}
43+
}
44+
45+
private getSeverityNumber(level: string): SeverityNumber {
46+
const levels = {
47+
trace: SeverityNumber.TRACE,
48+
debug: SeverityNumber.DEBUG,
49+
info: SeverityNumber.INFO,
50+
success: SeverityNumber.INFO2,
51+
warn: SeverityNumber.WARN,
52+
error: SeverityNumber.ERROR,
53+
fatal: SeverityNumber.FATAL
54+
}
55+
56+
return levels[level] || SeverityNumber.INFO
57+
}
58+
}

src/factories/DriverFactory.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { Driver } from '#src/drivers/Driver'
1313
import { FileDriver } from '#src/drivers/FileDriver'
1414
import { NullDriver } from '#src/drivers/NullDriver'
1515
import { LokiDriver } from '#src/drivers/LokiDriver'
16+
import { OtelDriver } from '#src/drivers/OtelDriver'
1617
import { SlackDriver } from '#src/drivers/SlackDriver'
1718
import { StackDriver } from '#src/drivers/StackDriver'
1819
import { LambdaDriver } from '#src/drivers/LambdaDriver'
@@ -32,6 +33,7 @@ export class DriverFactory {
3233
.set('file', { Driver: FileDriver })
3334
.set('null', { Driver: NullDriver })
3435
.set('loki', { Driver: LokiDriver })
36+
.set('otel', { Driver: OtelDriver })
3537
.set('slack', { Driver: SlackDriver })
3638
.set('stack', { Driver: StackDriver })
3739
.set('lambda', { Driver: LambdaDriver })

src/formatters/Formatter.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import rTracer from 'cls-rtracer'
1111

1212
import { hostname } from 'node:os'
13+
import { trace } from '@opentelemetry/api'
1314
import { Is, Color } from '@athenna/common'
1415

1516
export abstract class Formatter {
@@ -57,7 +58,17 @@ export abstract class Formatter {
5758
* Get the trace id for formatter.
5859
*/
5960
public traceId(): string | null {
60-
return (rTracer.id() || null) as any
61+
return (
62+
trace.getActiveSpan()?.spanContext().traceId ||
63+
((rTracer.id() || null) as any)
64+
)
65+
}
66+
67+
/**
68+
* Get the span id for formatter.
69+
*/
70+
public spanId(): string | null {
71+
return trace.getActiveSpan()?.spanContext().spanId || null
6172
}
6273

6374
/**

src/formatters/JsonFormatter.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ export class JsonFormatter extends Formatter {
1818
time: Date.now(),
1919
pid: this.pid(),
2020
hostname: this.hostname(),
21-
traceId: this.traceId()
21+
traceId: this.traceId(),
22+
spanId: this.spanId()
2223
}
2324

2425
if (Is.String(message)) {

src/formatters/RequestFormatter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export class RequestFormatter extends Formatter {
3737
path: ctx.request.baseUrl,
3838
createdAt: Date.now(),
3939
traceId: this.traceId(),
40+
spanId: this.spanId(),
4041
data: ctx.data
4142
}
4243

src/logger/Logger.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,10 @@ export class Logger extends Macroable {
7070
const runtimeConfigs: any = Json.copy(this.runtimeConfigs)
7171

7272
if (!runtimeConfigs.formatterConfig) {
73-
runtimeConfigs.formatterConfig = {}
73+
runtimeConfigs.formatterConfig = {}
7474
}
7575

76-
runtimeConfigs.formatterConfig.defaults = Json.copy(defaults)
76+
runtimeConfigs.formatterConfig.defaults = Json.copy(defaults)
7777

7878
logger.selection = {
7979
method: this.selection.method,
@@ -121,7 +121,10 @@ export class Logger extends Macroable {
121121
}
122122

123123
configs.forEach(config => {
124-
const driver = DriverFactory.fabricateVanilla({ ...config, ...this.runtimeConfigs })
124+
const driver = DriverFactory.fabricateVanilla({
125+
...config,
126+
...this.runtimeConfigs
127+
})
125128

126129
this.drivers.push(driver)
127130
})
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* @athenna/logger
3+
*
4+
* (c) João Lenon <lenon@athenna.io>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
import { OtelDriver } from '#src/drivers/OtelDriver'
11+
import { Test, AfterEach, BeforeEach, type Context } from '@athenna/test'
12+
import { context, ROOT_CONTEXT, trace, TraceFlags } from '@opentelemetry/api'
13+
import { logs, SeverityNumber, type LogRecord } from '@opentelemetry/api-logs'
14+
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'
15+
16+
class FakeLogger {
17+
public records: LogRecord[] = []
18+
19+
public emit(logRecord: LogRecord): void {
20+
this.records.push(logRecord)
21+
}
22+
}
23+
24+
class FakeLoggerProvider {
25+
public logger = new FakeLogger()
26+
27+
public getLogger() {
28+
return this.logger
29+
}
30+
}
31+
32+
export default class OtelDriverTest {
33+
@BeforeEach()
34+
public async beforeEach() {
35+
context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable())
36+
}
37+
38+
@AfterEach()
39+
public async afterEach() {
40+
logs.disable()
41+
context.disable()
42+
}
43+
44+
@Test()
45+
public async shouldEmitLogsUsingTheOpenTelemetryLoggerProvider({ assert }: Context) {
46+
const provider = new FakeLoggerProvider()
47+
48+
logs.setGlobalLoggerProvider(provider as any)
49+
50+
const driver = new OtelDriver({ formatter: 'json' })
51+
52+
driver.transport('error', 'failed to retrieve user')
53+
54+
assert.lengthOf(provider.logger.records, 1)
55+
assert.equal(provider.logger.records[0].severityNumber, SeverityNumber.ERROR)
56+
assert.equal(provider.logger.records[0].severityText, 'ERROR')
57+
assert.equal(provider.logger.records[0].attributes['athenna.log.level'], 'error')
58+
assert.equal(provider.logger.records[0].attributes['athenna.log.stream'], 'stderr')
59+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
60+
// @ts-ignore
61+
assert.equal(provider.logger.records[0].body.msg, 'failed to retrieve user')
62+
}
63+
64+
@Test()
65+
public async shouldEmitLogsWithTheActiveOtelContext({ assert }: Context) {
66+
const provider = new FakeLoggerProvider()
67+
const spanContext = {
68+
traceId: '11111111111111111111111111111111',
69+
spanId: '2222222222222222',
70+
traceFlags: TraceFlags.SAMPLED
71+
}
72+
73+
logs.setGlobalLoggerProvider(provider as any)
74+
75+
const driver = new OtelDriver({ formatter: 'json' })
76+
77+
context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => {
78+
driver.transport('info', 'hello')
79+
})
80+
81+
const emittedContext = provider.logger.records[0].context
82+
83+
assert.deepEqual(trace.getSpanContext(emittedContext), spanContext)
84+
}
85+
}

tests/unit/factories/DriverFactoryTest.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,18 @@ export default class DriverFactoryTest {
3838
public async shouldBeAbleToListAllAvailableDrivers({ assert }: Context) {
3939
const drivers = DriverFactory.availableDrivers()
4040

41-
assert.deepEqual(drivers, ['file', 'null', 'loki', 'slack', 'stack', 'lambda', 'console', 'discord', 'telegram'])
41+
assert.deepEqual(drivers, [
42+
'file',
43+
'null',
44+
'loki',
45+
'otel',
46+
'slack',
47+
'stack',
48+
'lambda',
49+
'console',
50+
'discord',
51+
'telegram'
52+
])
4253
}
4354

4455
@Test()

0 commit comments

Comments
 (0)