Skip to content

Commit 413ff56

Browse files
committed
Support console logging
1 parent ee31cbc commit 413ff56

File tree

11 files changed

+264
-67
lines changed

11 files changed

+264
-67
lines changed

.changeset/smart-cities-attack.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@pydantic/logfire-browser": minor
3+
"logfire": minor
4+
---
5+
6+
Support logging spans in the console

examples/node/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ logfire.configure({
55
serviceName: 'example-node-script',
66
serviceVersion: '1.0.0',
77
environment: 'staging',
8-
diagLogLevel: logfire.DiagLogLevel.DEBUG,
8+
diagLogLevel: logfire.DiagLogLevel.NONE,
9+
console: false
910
})
1011

1112

package-lock.json

Lines changed: 4 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
2+
/* eslint-disable @typescript-eslint/no-deprecated */
3+
import { Context } from '@opentelemetry/api'
4+
import { ExportResult, ExportResultCode, hrTimeToMicroseconds } from '@opentelemetry/core'
5+
import { ReadableSpan, SimpleSpanProcessor, Span, SpanExporter, SpanProcessor } from '@opentelemetry/sdk-trace-web'
6+
import { ATTR_HTTP_URL } from '@opentelemetry/semantic-conventions/incubating'
7+
8+
// not present in the semantic conventions
9+
const ATTR_TARGET_XPATH = 'target_xpath'
10+
const ATTR_EVENT_TYPE = 'event_type'
11+
12+
export const LevelLabels = {
13+
1: 'trace',
14+
5: 'debug',
15+
9: 'info',
16+
10: 'notice',
17+
13: 'warning',
18+
17: 'error',
19+
21: 'fatal',
20+
} as const
21+
22+
const Colors = {
23+
debug: '#E3E3E3',
24+
error: '#EA4335',
25+
fatal: '#EA4335',
26+
info: '#9EC1FB',
27+
notice: '#A5D490',
28+
'on-debug': '#636262',
29+
'on-error': '#FFEDE9',
30+
'on-fatal': '#FFEDE9',
31+
'on-info': '#063175',
32+
'on-notice': '#222222',
33+
'on-trace': '#636262',
34+
'on-warning': '#613A0D',
35+
trace: '#E3E3E3',
36+
warning: '#EFB77A',
37+
} as const
38+
39+
class LogfireConsoleSpanExporter implements SpanExporter {
40+
export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void {
41+
this.sendSpans(spans, resultCallback)
42+
}
43+
44+
forceFlush(): Promise<void> {
45+
return Promise.resolve()
46+
}
47+
shutdown(): Promise<void> {
48+
this.sendSpans([])
49+
return this.forceFlush()
50+
}
51+
/**
52+
* converts span info into more readable format
53+
* @param span
54+
*/
55+
private exportInfo(span: ReadableSpan) {
56+
return {
57+
attributes: span.attributes,
58+
duration: hrTimeToMicroseconds(span.duration),
59+
events: span.events,
60+
id: span.spanContext().spanId,
61+
instrumentationScope: span.instrumentationScope,
62+
kind: span.kind,
63+
links: span.links,
64+
name: span.name,
65+
parentSpanContext: span.parentSpanContext,
66+
resource: {
67+
attributes: span.resource.attributes,
68+
},
69+
status: span.status,
70+
timestamp: hrTimeToMicroseconds(span.startTime),
71+
traceId: span.spanContext().traceId,
72+
traceState: span.spanContext().traceState?.serialize(),
73+
}
74+
}
75+
76+
private sendSpans(spans: ReadableSpan[], done?: (result: ExportResult) => void): void {
77+
for (const span of spans) {
78+
const type = LevelLabels[span.attributes['logfire.level_num'] as keyof typeof LevelLabels] ?? 'info'
79+
80+
const { attributes, name, ...rest } = this.exportInfo(span)
81+
console.log(
82+
`%cLogfire %c${type}`,
83+
'background-color: #E520E9; color: #FFFFFF',
84+
`background-color: ${Colors[`on-${type}`]}; color: ${Colors[type]}`,
85+
name,
86+
attributes,
87+
rest
88+
)
89+
}
90+
if (done) {
91+
done({ code: ExportResultCode.SUCCESS })
92+
}
93+
}
94+
}
95+
96+
export class LogfireSpanProcessor implements SpanProcessor {
97+
private console?: SpanProcessor
98+
private wrapped: SpanProcessor
99+
100+
constructor(wrapped: SpanProcessor, enableConsole: boolean) {
101+
if (enableConsole) {
102+
this.console = new SimpleSpanProcessor(new LogfireConsoleSpanExporter())
103+
}
104+
this.wrapped = wrapped
105+
}
106+
107+
async forceFlush(): Promise<void> {
108+
await this.console?.forceFlush()
109+
return this.wrapped.forceFlush()
110+
}
111+
112+
onEnd(span: ReadableSpan): void {
113+
this.console?.onEnd(span)
114+
// Note: this is too late for the regular node instrumentation. The opentelemetry API rejects the non-primitive attribute values.
115+
// Instead, the serialization happens at the `logfire.span, logfire.startSpan`, etc.
116+
// Object.assign(span.attributes, serializeAttributes(span.attributes))
117+
this.wrapped.onEnd(span)
118+
}
119+
120+
onStart(span: Span, parentContext: Context): void {
121+
// make the fetch spans more descriptive
122+
if (ATTR_HTTP_URL in span.attributes) {
123+
const url = new URL(span.attributes[ATTR_HTTP_URL] as string)
124+
Reflect.set(span, 'name', `${span.name} ${url.pathname}`)
125+
}
126+
127+
// same for the interaction spans
128+
if (ATTR_TARGET_XPATH in span.attributes) {
129+
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
130+
Reflect.set(span, 'name', `${span.attributes[ATTR_EVENT_TYPE] ?? 'unknown'} ${span.attributes[ATTR_TARGET_XPATH] ?? ''}`)
131+
}
132+
this.console?.onStart(span, parentContext)
133+
this.wrapped.onStart(span, parentContext)
134+
}
135+
136+
async shutdown(): Promise<void> {
137+
await this.console?.shutdown()
138+
return this.wrapped.shutdown()
139+
}
140+
}

packages/logfire-browser/src/index.ts

Lines changed: 10 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,8 @@
1-
/* eslint-disable @typescript-eslint/no-deprecated */
2-
import { Context, ContextManager, diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api'
1+
import { ContextManager, diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api'
32
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
43
import { Instrumentation, registerInstrumentations } from '@opentelemetry/instrumentation'
54
import { resourceFromAttributes } from '@opentelemetry/resources'
6-
import {
7-
BatchSpanProcessor,
8-
BufferConfig,
9-
ReadableSpan,
10-
Span,
11-
SpanProcessor,
12-
StackContextManager,
13-
WebTracerProvider,
14-
} from '@opentelemetry/sdk-trace-web'
5+
import { BatchSpanProcessor, BufferConfig, StackContextManager, WebTracerProvider } from '@opentelemetry/sdk-trace-web'
156
import {
167
ATTR_SERVICE_NAME,
178
ATTR_SERVICE_VERSION,
@@ -27,26 +18,26 @@ import {
2718
ATTR_BROWSER_MOBILE,
2819
ATTR_BROWSER_PLATFORM,
2920
ATTR_DEPLOYMENT_ENVIRONMENT_NAME,
30-
ATTR_HTTP_URL,
3121
} from '@opentelemetry/semantic-conventions/incubating'
32-
import { ULIDGenerator } from '@pydantic/logfire-api'
3322
import * as logfireApi from '@pydantic/logfire-api'
23+
import { ULIDGenerator } from '@pydantic/logfire-api'
3424

25+
import { LogfireSpanProcessor } from './LogfireSpanProcessor'
3526
import { OTLPTraceExporterWithDynamicHeaders } from './OTLPTraceExporterWithDynamicHeaders'
3627
export { DiagLogLevel } from '@opentelemetry/api'
3728
export * from '@pydantic/logfire-api'
3829

3930
type TraceExporterConfig = NonNullable<typeof OTLPTraceExporter extends new (config: infer T) => unknown ? T : never>
4031

41-
// not present in the semantic conventions
42-
const ATTR_TARGET_XPATH = 'target_xpath'
43-
const ATTR_EVENT_TYPE = 'event_type'
44-
4532
export interface LogfireConfigOptions {
4633
/**
4734
* The configuration of the batch span processor.
4835
*/
4936
batchSpanProcessorConfig?: BufferConfig
37+
/**
38+
* Whether to log the spans to the console in addition to sending them to the Logfire API.
39+
*/
40+
console?: boolean
5041
/**
5142
* Pass a context manager (e.g. ZoneContextManager) to use.
5243
*/
@@ -143,7 +134,8 @@ export function configure(options: LogfireConfigOptions) {
143134
options.traceExporterHeaders ?? defaultTraceExporterHeaders
144135
),
145136
options.batchSpanProcessorConfig
146-
)
137+
),
138+
Boolean(options.console)
147139
),
148140
],
149141
})
@@ -165,41 +157,3 @@ export function configure(options: LogfireConfigOptions) {
165157
diag.info('logfire-browser: shut down complete')
166158
}
167159
}
168-
169-
class LogfireSpanProcessor implements SpanProcessor {
170-
private wrapped: SpanProcessor
171-
172-
constructor(wrapped: SpanProcessor) {
173-
this.wrapped = wrapped
174-
}
175-
176-
async forceFlush(): Promise<void> {
177-
return this.wrapped.forceFlush()
178-
}
179-
180-
onEnd(span: ReadableSpan): void {
181-
// Note: this is too late for the regular node instrumentation. The opentelemetry API rejects the non-primitive attribute values.
182-
// Instead, the serialization happens at the `logfire.span, logfire.startSpan`, etc.
183-
// Object.assign(span.attributes, serializeAttributes(span.attributes))
184-
this.wrapped.onEnd(span)
185-
}
186-
187-
onStart(span: Span, parentContext: Context): void {
188-
// make the fetch spans more descriptive
189-
if (ATTR_HTTP_URL in span.attributes) {
190-
const url = new URL(span.attributes[ATTR_HTTP_URL] as string)
191-
Reflect.set(span, 'name', `${span.name} ${url.pathname}`)
192-
}
193-
194-
// same for the interaction spans
195-
if (ATTR_TARGET_XPATH in span.attributes) {
196-
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
197-
Reflect.set(span, 'name', `${span.attributes[ATTR_EVENT_TYPE] ?? 'unknown'} ${span.attributes[ATTR_TARGET_XPATH] ?? ''}`)
198-
}
199-
this.wrapped.onStart(span, parentContext)
200-
}
201-
202-
async shutdown(): Promise<void> {
203-
return this.wrapped.shutdown()
204-
}
205-
}

packages/logfire/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@
4949
"test": "vitest --passWithNoTests"
5050
},
5151
"dependencies": {
52-
"@pydantic/logfire-api": "*"
52+
"@pydantic/logfire-api": "*",
53+
"picocolors": "^1.1.1"
5354
},
5455
"devDependencies": {
5556
"@opentelemetry/api": "^1.9.0",
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { ExportResult, ExportResultCode, hrTimeToMicroseconds } from '@opentelemetry/core'
2+
import { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'
3+
import pc from 'picocolors'
4+
5+
const LevelLabels = {
6+
1: 'trace',
7+
5: 'debug',
8+
9: 'info',
9+
10: 'notice',
10+
13: 'warning',
11+
17: 'error',
12+
21: 'fatal',
13+
} as const
14+
15+
const ColorMap = {
16+
debug: pc.blue,
17+
error: pc.red,
18+
fatal: pc.magenta,
19+
info: pc.cyan,
20+
notice: pc.green,
21+
trace: pc.gray,
22+
warning: pc.yellow,
23+
}
24+
25+
export class LogfireConsoleSpanExporter implements SpanExporter {
26+
export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void {
27+
this.sendSpans(spans, resultCallback)
28+
}
29+
30+
forceFlush(): Promise<void> {
31+
return Promise.resolve()
32+
}
33+
shutdown(): Promise<void> {
34+
this.sendSpans([])
35+
return this.forceFlush()
36+
}
37+
38+
/**
39+
* converts span info into more readable format
40+
* @param span
41+
*/
42+
private exportInfo(span: ReadableSpan) {
43+
return {
44+
attributes: span.attributes,
45+
duration: hrTimeToMicroseconds(span.duration),
46+
events: span.events,
47+
id: span.spanContext().spanId,
48+
instrumentationScope: span.instrumentationScope,
49+
kind: span.kind,
50+
links: span.links,
51+
name: span.name,
52+
parentSpanContext: span.parentSpanContext,
53+
resource: {
54+
attributes: span.resource.attributes,
55+
},
56+
status: span.status,
57+
timestamp: hrTimeToMicroseconds(span.startTime),
58+
traceId: span.spanContext().traceId,
59+
traceState: span.spanContext().traceState?.serialize(),
60+
}
61+
}
62+
63+
private sendSpans(spans: ReadableSpan[], done?: (result: ExportResult) => void): void {
64+
for (const span of spans) {
65+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
66+
const type = LevelLabels[span.attributes['logfire.level_num'] as keyof typeof LevelLabels] ?? 'info'
67+
68+
const { attributes, name, ...rest } = this.exportInfo(span)
69+
console.log(`${pc.bgMagentaBright('Logfire')} ${ColorMap[type](type)} ${name}`)
70+
console.dir(attributes)
71+
console.dir(rest)
72+
}
73+
if (done) {
74+
done({ code: ExportResultCode.SUCCESS })
75+
}
76+
}
77+
}

0 commit comments

Comments
 (0)