Skip to content

Commit fcc7012

Browse files
committed
Wire OTel LoggerProvider into logfire-node + expose forceFlush/shutdown
`withOnlineEvaluation` emits `gen_ai.evaluation.result` log records via the OTel api-logs API, but logfire-node never configured a LoggerProvider or OTLP logs exporter — records fell through to the OTel SDK's default `localhost:4318/v1/logs`, which on most setups silently dropped them. Add a BatchLogRecordProcessor pointing at `${baseUrl}/v1/logs` (alongside the existing trace + metric exporters) by passing it to NodeSDK's `logRecordProcessors`. Verified end-to-end against the local platform: log records now POST to `localhost:3000/v1/logs` and the Live Evaluations UI lights up with target/eval rows. Also expose `logfire.forceFlush()` and `logfire.shutdown()` from logfire-node, mirroring Python's `logfire.force_flush()`. The previous `beforeExit` cleanup was racy for short-lived scripts that top-level-await once and exit before the OTel batch processor's 5-second scheduledDelay fires. Demos now call these explicitly so spans land deterministically. Drop `dotenv/config` from the demo scripts: env-loaded config can silently override the local-dev defaults (caught when `LOGFIRE_BASE_URL` in a stray .env pointed at port 8000 instead of 3000). Demos now use their hard-coded local-platform defaults; users wanting to point elsewhere can set env vars explicitly via shell.
1 parent 185293a commit fcc7012

8 files changed

Lines changed: 88 additions & 6 deletions

File tree

examples/node/demo_evals.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
* to inspect the resulting experiment.
2020
*/
2121

22-
import 'dotenv/config'
2322
import { resolve } from 'node:path'
2423

2524
import * as logfire from '@pydantic/logfire-node'
@@ -143,5 +142,8 @@ async function evaluateDataset(): Promise<void> {
143142
}
144143

145144
await evaluateDataset()
146-
// Force-flush the OTel SDK so the experiment lands before the process exits.
147-
await new Promise((resolve) => setTimeout(resolve, 1500))
145+
// Force-flush pending spans so we don't race the OTel batch processor's
146+
// 5-second scheduled-delay against process exit. Mirrors the Python demo's
147+
// `logfire.force_flush()` at the end of the script.
148+
await logfire.forceFlush()
149+
await logfire.shutdown()

examples/node/demo_online_evals.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
* `math_agent` — http://localhost:3000/e2e-test/test-e2e-project/evals/live
1515
*/
1616

17-
import 'dotenv/config'
1817
import * as logfire from '@pydantic/logfire-node'
1918
import {
2019
Evaluator,
@@ -132,6 +131,6 @@ await runTarget('math_agent', MATH_CASES)
132131

133132
console.log('\nFlushing pending evaluations…')
134133
await waitForEvaluations({ timeoutMs: 10_000 })
135-
// Give the OTel batch processor a moment to ship the events.
136-
await new Promise((r) => setTimeout(r, 1500))
134+
await logfire.forceFlush()
135+
await logfire.shutdown()
137136
console.log('Done. Open the Live Evaluations UI to confirm events landed for both targets.')

packages/logfire-node/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,27 +68,33 @@
6868
},
6969
"devDependencies": {
7070
"@opentelemetry/api": "catalog:",
71+
"@opentelemetry/api-logs": "catalog:",
7172
"@opentelemetry/auto-instrumentations-node": "catalog:",
7273
"@opentelemetry/context-async-hooks": "catalog:",
7374
"@opentelemetry/core": "catalog:",
75+
"@opentelemetry/exporter-logs-otlp-proto": "catalog:",
7476
"@opentelemetry/exporter-metrics-otlp-proto": "catalog:",
7577
"@opentelemetry/exporter-trace-otlp-proto": "catalog:",
7678
"@opentelemetry/instrumentation": "catalog:",
7779
"@opentelemetry/resources": "catalog:",
80+
"@opentelemetry/sdk-logs": "catalog:",
7881
"@opentelemetry/sdk-metrics": "catalog:",
7982
"@opentelemetry/sdk-node": "catalog:",
8083
"@opentelemetry/sdk-trace-base": "catalog:",
8184
"@opentelemetry/semantic-conventions": "catalog:"
8285
},
8386
"peerDependencies": {
8487
"@opentelemetry/api": "catalog:",
88+
"@opentelemetry/api-logs": "catalog:",
8589
"@opentelemetry/auto-instrumentations-node": "catalog:",
8690
"@opentelemetry/context-async-hooks": "catalog:",
8791
"@opentelemetry/core": "catalog:",
92+
"@opentelemetry/exporter-logs-otlp-proto": "catalog:",
8893
"@opentelemetry/exporter-metrics-otlp-proto": "catalog:",
8994
"@opentelemetry/exporter-trace-otlp-proto": "catalog:",
9095
"@opentelemetry/instrumentation": "catalog:",
9196
"@opentelemetry/resources": "catalog:",
97+
"@opentelemetry/sdk-logs": "catalog:",
9298
"@opentelemetry/sdk-metrics": "catalog:",
9399
"@opentelemetry/sdk-node": "catalog:",
94100
"@opentelemetry/sdk-trace-base": "catalog:",

packages/logfire-node/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@ import {
2424

2525
// Import all exports to construct default export
2626
import * as logfireConfigExports from './logfireConfig'
27+
import { forceFlush, shutdown } from './sdk'
2728

2829
export * from './logfireConfig'
30+
export { forceFlush, shutdown } from './sdk'
2931
export { DiagLogLevel } from '@opentelemetry/api'
3032
export * from 'logfire'
3133

@@ -37,6 +39,7 @@ export default {
3739
DiagLogLevel,
3840
error,
3941
fatal,
42+
forceFlush,
4043
info,
4144
// Re-export all from logfire
4245
Level,
@@ -49,6 +52,7 @@ export default {
4952
resolveBaseUrl,
5053
resolveSendToLogfire,
5154
serializeAttributes,
55+
shutdown,
5256
span,
5357
startSpan,
5458
trace,

packages/logfire-node/src/logfireConfig.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export interface LogfireConfigOptions {
129129
const DEFAULT_OTEL_SCOPE = 'logfire'
130130
const TRACE_ENDPOINT_PATH = 'v1/traces'
131131
const METRIC_ENDPOINT_PATH = 'v1/metrics'
132+
const LOGS_ENDPOINT_PATH = 'v1/logs'
132133
const DEFAULT_AUTO_INSTRUMENTATION_CONFIG: InstrumentationConfigMap = {
133134
// https://opentelemetry.io/docs/languages/js/libraries/#registration
134135
// This particular instrumentation creates a lot of noise on startup
@@ -148,6 +149,7 @@ export interface LogfireConfig {
148149
distributedTracing: boolean
149150
idGenerator: IdGenerator
150151
instrumentations: Instrumentation[]
152+
logsExporterUrl: string
151153
metricExporterUrl: string
152154
metrics: false | MetricsOptions | undefined
153155
nodeAutoInstrumentations: InstrumentationConfigMap
@@ -171,6 +173,7 @@ const DEFAULT_LOGFIRE_CONFIG: LogfireConfig = {
171173
distributedTracing: true,
172174
idGenerator: new logfireApi.ULIDGenerator(),
173175
instrumentations: [],
176+
logsExporterUrl: '',
174177
metricExporterUrl: '',
175178
metrics: undefined,
176179
nodeAutoInstrumentations: DEFAULT_AUTO_INSTRUMENTATION_CONFIG,
@@ -212,6 +215,7 @@ export function configure(config: LogfireConfigOptions = {}) {
212215
distributedTracing: resolveDistributedTracing(cnf.distributedTracing),
213216
idGenerator: cnf.advanced?.idGenerator ?? new logfireApi.ULIDGenerator(),
214217
instrumentations: cnf.instrumentations ?? [],
218+
logsExporterUrl: `${baseUrl}/${LOGS_ENDPOINT_PATH}`,
215219
metricExporterUrl: `${baseUrl}/${METRIC_ENDPOINT_PATH}`,
216220
metrics: cnf.metrics,
217221
nodeAutoInstrumentations: cnf.nodeAutoInstrumentations ?? DEFAULT_AUTO_INSTRUMENTATION_CONFIG,
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-proto'
2+
import { BatchLogRecordProcessor, LogRecordProcessor } from '@opentelemetry/sdk-logs'
3+
4+
import { logfireConfig } from './logfireConfig'
5+
6+
/**
7+
* Returns a `BatchLogRecordProcessor` wired to the Logfire OTLP /v1/logs endpoint.
8+
* Used by `withOnlineEvaluation` to ship `gen_ai.evaluation.result` log events.
9+
*
10+
* Returns null when sendToLogfire is disabled — the OTel API logs methods will
11+
* become no-ops.
12+
*/
13+
export function logfireLogRecordProcessor(): LogRecordProcessor | null {
14+
if (!logfireConfig.sendToLogfire || !logfireConfig.token) {
15+
return null
16+
}
17+
return new BatchLogRecordProcessor(
18+
new OTLPLogExporter({
19+
headers: logfireConfig.authorizationHeaders,
20+
url: logfireConfig.logsExporterUrl,
21+
})
22+
)
23+
}

packages/logfire-node/src/sdk.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,37 @@ import { reportError, TailSamplingProcessor, ULIDGenerator } from 'logfire'
2323
import { getEvalsSpanProcessor } from 'logfire/evals'
2424

2525
import { logfireConfig } from './logfireConfig'
26+
import { logfireLogRecordProcessor } from './logsExporter'
2627
import { periodicMetricReader } from './metricExporter'
2728
import { logfireSpanProcessor } from './traceExporter'
2829
import { removeEmptyKeys } from './utils'
2930

31+
let activeSdk: NodeSDK | undefined
32+
let activeProcessor: import('@opentelemetry/sdk-trace-base').SpanProcessor | undefined
33+
34+
/**
35+
* Force-flush all pending spans to the configured exporter. Mirrors Python's
36+
* `logfire.force_flush()`. Call this before process exit when the default
37+
* `beforeExit` cleanup might not have time to finish (e.g. short scripts that
38+
* top-level-await once and exit).
39+
*/
40+
export async function forceFlush(): Promise<void> {
41+
if (activeProcessor) {
42+
await activeProcessor.forceFlush()
43+
}
44+
}
45+
46+
/**
47+
* Shut down the OTel SDK, flushing pending spans and metrics. Idempotent —
48+
* subsequent calls are no-ops. Mirrors Python's `logfire.shutdown()`.
49+
*/
50+
export async function shutdown(): Promise<void> {
51+
if (activeSdk) {
52+
await activeSdk.shutdown()
53+
activeSdk = undefined
54+
}
55+
}
56+
3057
const LOGFIRE_ATTRIBUTES_NAMESPACE = 'logfire'
3158
const RESOURCE_ATTRIBUTES_CODE_ROOT_PATH = `${LOGFIRE_ATTRIBUTES_NAMESPACE}.code.root_path`
3259

@@ -60,16 +87,20 @@ export function start() {
6087
if (logfireConfig.sampling?.tail) {
6188
processor = new TailSamplingProcessor(processor, logfireConfig.sampling.tail)
6289
}
90+
activeProcessor = processor
6391

6492
const headRate = logfireConfig.sampling?.head
6593
const sampler =
6694
headRate !== undefined && headRate < 1.0 ? new ParentBasedSampler({ root: new TraceIdRatioBasedSampler(headRate) }) : undefined
6795

96+
const logProcessor = logfireLogRecordProcessor()
97+
6898
const sdk = new NodeSDK({
6999
autoDetectResources: false,
70100
contextManager,
71101
idGenerator: new ULIDGenerator(),
72102
instrumentations: [getNodeAutoInstrumentations(logfireConfig.nodeAutoInstrumentations), ...logfireConfig.instrumentations],
103+
...(logProcessor ? { logRecordProcessors: [logProcessor] } : {}),
73104
metricReader: logfireConfig.metrics === false ? undefined : periodicMetricReader(),
74105
resource,
75106
...(sampler ? { sampler } : {}),
@@ -82,6 +113,7 @@ export function start() {
82113
metrics.setGlobalMeterProvider(meterProvider)
83114
}
84115

116+
activeSdk = sdk
85117
sdk.start()
86118
diag.info('logfire: starting')
87119

pnpm-lock.yaml

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

0 commit comments

Comments
 (0)