Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/spankind-manual-span-apis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'logfire': minor
---

Expose OpenTelemetry `SpanKind` in the manual span APIs. `span()`, `startSpan()`, `startPendingSpan()`, and `instrument()` accept an optional `kind` that is forwarded to the tracer, and pending span placeholders keep the kind of their real span. Omitting `kind` continues to produce `INTERNAL` spans.
25 changes: 25 additions & 0 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,31 @@ Manual tracing and logging:
- `reportError(message, error, extraAttributes?, options?)`
- `Level`

Manual spans accept an optional OpenTelemetry `SpanKind` so remote operations
(HTTP/RPC/WebSocket clients, server handlers not covered by
auto-instrumentation, queue producers and consumers) export with an accurate
kind instead of the `INTERNAL` default:

```ts
import { SpanKind } from '@opentelemetry/api'
import * as logfire from 'logfire'

await logfire.span('load live view records', {
kind: SpanKind.CLIENT,
attributes: {
'websocket.endpoint': 'historical_query',
},
callback: async (span) => {
// perform the outbound request/response operation
},
})
```

`startSpan()`, `startPendingSpan()`, and `instrument()` accept the same `kind`
option, pending span placeholders keep the kind of their real span, and
omitting `kind` continues to produce `INTERNAL` spans. Log helpers do not take
a kind; zero-duration Logfire logs stay `INTERNAL`.

Configuration helpers and utilities:

- `configureLogfireApi()`
Expand Down
34 changes: 34 additions & 0 deletions packages/logfire-api/src/__test__/collectSpans.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Test helper: spin up a real `BasicTracerProvider` + `InMemorySpanExporter`,
* let the caller assemble the processor pipeline around the exporter's primary
* processor, configure the Logfire API, run the test body, and return the
* captured spans. Asserting against actual exporter output catches issues that
* mock-based tests miss (span kind, span-type attributes, serialization, etc.).
*/

import type { ReadableSpan, SpanProcessor } from '@opentelemetry/sdk-trace-base'

import { trace as TraceAPI } from '@opentelemetry/api'
import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'

import { configureLogfireApi } from '../index'

export async function collectSpans(
createProcessors: (primary: SpanProcessor) => SpanProcessor[],
run: () => void
): Promise<ReadableSpan[]> {
const exporter = new InMemorySpanExporter()
const primary = new SimpleSpanProcessor(exporter)
const provider = new BasicTracerProvider({ spanProcessors: createProcessors(primary) })
TraceAPI.setGlobalTracerProvider(provider)
configureLogfireApi({ otelScope: 'logfire', scrubbing: false })

try {
run()
await provider.forceFlush()
return exporter.getFinishedSpans()
} finally {
await provider.shutdown()
TraceAPI.disable()
}
}
49 changes: 38 additions & 11 deletions packages/logfire-api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Attributes, Context, Exception, HrTime, Span } from '@opentelemetry/api'
import type { Attributes, Context, Exception, HrTime, Span, SpanKind } from '@opentelemetry/api'
import {
INVALID_SPAN_CONTEXT,
SpanStatusCode,
Expand Down Expand Up @@ -84,7 +84,16 @@ export interface LogOptions {
tags?: string[]
}

export type StartPendingSpanOptions = Omit<LogOptions, 'log'>
export interface SpanOptions extends LogOptions {
/**
* The OpenTelemetry SpanKind for the span, e.g. SpanKind.CLIENT for an
* outbound request/response operation.
* Defaults to SpanKind.INTERNAL when omitted.
*/
kind?: SpanKind
}

export type StartPendingSpanOptions = Omit<SpanOptions, 'log'>

export interface LogfireClientSettings {
level?: LogFireLevel
Expand Down Expand Up @@ -114,6 +123,11 @@ export interface InstrumentOptions {
* best-effort parameter-name extraction from function source.
*/
extractArgs?: boolean | readonly string[]
/**
* The OpenTelemetry SpanKind for the instrumented call span.
* Defaults to SpanKind.INTERNAL when omitted.
*/
kind?: SpanKind
/**
* The log level for the instrumented call span.
*/
Expand Down Expand Up @@ -147,7 +161,7 @@ interface LogfireSpanStart {

type LevelSource = 'default' | 'explicit'

interface ResolvedLogOptions extends Omit<LogOptions, 'level' | 'tags'> {
interface ResolvedLogOptions extends Omit<SpanOptions, 'level' | 'tags'> {
level: LogFireLevel
levelSource: LevelSource
tags: string[]
Expand Down Expand Up @@ -182,7 +196,7 @@ function mergeClientSettings(parent: ResolvedLogfireClientSettings, child: Logfi
return merged
}

function resolveLogOptions(settings: ResolvedLogfireClientSettings, options: LogOptions | undefined): ResolvedLogOptions {
function resolveLogOptions(settings: ResolvedLogfireClientSettings, options: SpanOptions | undefined): ResolvedLogOptions {
const merged: ResolvedLogOptions = {
...options,
level: Level.Info,
Expand Down Expand Up @@ -415,24 +429,27 @@ function startSpanWithSettings(
settings: ResolvedLogfireClientSettings,
msgTemplate: string,
attributes: Record<string, unknown> = {},
options: LogOptions = {}
options: SpanOptions = {}
): Span {
const resolvedOptions = resolveLogOptions(settings, options)
if (shouldFilterLevel(resolvedOptions.level, resolvedOptions.levelSource, resolvedOptions.log === true)) {
return getNoopSpan()
}
const spanStart = buildLogfireSpanStart(msgTemplate, attributes, resolvedOptions)

// Logs are always INTERNAL: a runtime options object carrying `kind` must not
// change the exported kind of zero-duration Logfire logs.
const kind = resolvedOptions.log === true ? undefined : resolvedOptions.kind
const span = logfireApiConfig.tracer.startSpan(
spanStart.name,
{ attributes: spanStart.attributes },
{ attributes: spanStart.attributes, ...(kind !== undefined ? { kind } : {}) },
getSpanStartContext(resolvedOptions.parentSpan)
)

return span
}

export function startSpan(msgTemplate: string, attributes: Record<string, unknown> = {}, options: LogOptions = {}): Span {
export function startSpan(msgTemplate: string, attributes: Record<string, unknown> = {}, options: SpanOptions = {}): Span {
return startSpanWithSettings(ROOT_CLIENT_SETTINGS, msgTemplate, attributes, options)
}

Expand All @@ -452,9 +469,10 @@ function startPendingSpanWithSettings(
}
const spanStart = buildLogfireSpanStart(msgTemplate, attributes, resolvedOptions)
const parentContext = getSpanStartContext(resolvedOptions.parentSpan)
const spanKindOptions = resolvedOptions.kind !== undefined ? { kind: resolvedOptions.kind } : {}
const realSpan = logfireApiConfig.tracer.startSpan(
spanStart.name,
{ attributes: spanStart.attributes },
{ attributes: spanStart.attributes, ...spanKindOptions },
setPendingSpanSuppressed(parentContext)
)

Expand All @@ -477,6 +495,7 @@ function startPendingSpanWithSettings(
[ATTRIBUTES_SPAN_TYPE_KEY]: 'pending_span',
},
startTime,
...spanKindOptions,
},
TheTraceAPI.setSpan(parentContext, realSpan)
)
Expand All @@ -494,12 +513,13 @@ export function startPendingSpan(
}

type SpanCallback<R> = (activeSpan: Span) => R
type SpanArgsVariant1<R> = [Record<string, unknown>, LogOptions, SpanCallback<R>]
type SpanArgsVariant1<R> = [Record<string, unknown>, SpanOptions, SpanCallback<R>]
type SpanArgsVariant2<R> = [
{
_spanName?: string
attributes?: Record<string, unknown>
callback: SpanCallback<R>
kind?: SpanKind
level?: LogFireLevel
parentSpan?: Span
tags?: string[]
Expand All @@ -513,7 +533,7 @@ type SpanArgsVariant2<R> = [
* The span will be ended automatically after the function call.
*/
export function span<R>(msgTemplate: string, options: SpanArgsVariant2<R>[0]): R
export function span<R>(msgTemplate: string, attributes: Record<string, unknown>, options: LogOptions, callback: (span: Span) => R): R
export function span<R>(msgTemplate: string, attributes: Record<string, unknown>, options: SpanOptions, callback: (span: Span) => R): R
export function span<R>(msgTemplate: string, ...args: SpanArgsVariant1<R> | SpanArgsVariant2<R>): R {
return spanWithSettings(ROOT_CLIENT_SETTINGS, msgTemplate, ...args)
}
Expand All @@ -528,6 +548,7 @@ function spanWithSettings<R>(
let levelSource: LevelSource
let tags: string[]
let callback!: SpanCallback<R>
let kind: SpanKind | undefined
let parentSpan: Span | undefined
let spanName: string | undefined
if (args.length === 1) {
Expand All @@ -538,6 +559,7 @@ function spanWithSettings<R>(
levelSource = resolvedLevel.source
tags = mergeTags(settings.tags, options.tags)
callback = options.callback
kind = options.kind
parentSpan = options.parentSpan
spanName = options._spanName
} else {
Expand All @@ -546,6 +568,7 @@ function spanWithSettings<R>(
level = options.level
levelSource = options.levelSource
tags = options.tags
kind = options.kind
parentSpan = options.parentSpan
spanName = options._spanName
callback = args[2]
Expand All @@ -562,6 +585,7 @@ function spanWithSettings<R>(
spanName ?? msgTemplate,
{
attributes: serializedAttributes,
...(kind !== undefined ? { kind } : {}),
},
context,
(span: Span) => {
Expand Down Expand Up @@ -670,6 +694,9 @@ function instrumentWithSettings<F extends InstrumentableFunction>(
return result
},
}
if (options.kind !== undefined) {
spanOptions.kind = options.kind
}
if (options.level !== undefined) {
spanOptions.level = options.level
}
Expand Down Expand Up @@ -966,7 +993,7 @@ export interface LogfireClient {
reportError(message: string, error: unknown, extraAttributes?: Record<string, unknown>, options?: ReportErrorOptions): void
span: typeof span
startPendingSpan(message: string, attributes?: Record<string, unknown>, options?: StartPendingSpanOptions): Span
startSpan(message: string, attributes?: Record<string, unknown>, options?: LogOptions): Span
startSpan(message: string, attributes?: Record<string, unknown>, options?: SpanOptions): Span
trace(message: string, attributes?: Record<string, unknown>, options?: LogOptions): void
warning(message: string, attributes?: Record<string, unknown>, options?: LogOptions): void
withSettings(settings: LogfireClientSettings): LogfireClient
Expand Down
94 changes: 94 additions & 0 deletions packages/logfire-api/src/spanKind.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { SpanKind } from '@opentelemetry/api'
import { describe, expect, test } from 'vite-plus/test'

import type { SpanOptions } from './index'

import { collectSpans } from './__test__/collectSpans'
import { ATTRIBUTES_SPAN_TYPE_KEY } from './constants'
import { info, instrument, span, startPendingSpan, startSpan } from './index'
import { PendingSpanProcessor } from './PendingSpanProcessor'

describe('span kind integration', () => {
test('span() with the options object exports the provided kind', async () => {
const spans = await collectSpans(
(primary) => [primary],
() => {
span('outbound request', { callback: () => undefined, kind: SpanKind.CLIENT })
}
)

expect(spans.map((exported) => exported.kind)).toEqual([SpanKind.CLIENT])
})

test('span() with positional attributes and options exports the provided kind', async () => {
const spans = await collectSpans(
(primary) => [primary],
() => {
span('handle request', {}, { kind: SpanKind.SERVER }, () => undefined)
}
)

expect(spans.map((exported) => exported.kind)).toEqual([SpanKind.SERVER])
})

test('span() without a kind keeps the INTERNAL default', async () => {
const spans = await collectSpans(
(primary) => [primary],
() => {
span('internal work', { callback: () => undefined })
}
)

expect(spans.map((exported) => exported.kind)).toEqual([SpanKind.INTERNAL])
})

test('startSpan() exports the provided kind and defaults to INTERNAL', async () => {
const spans = await collectSpans(
(primary) => [primary],
() => {
startSpan('publish message', {}, { kind: SpanKind.PRODUCER }).end()
startSpan('plain work').end()
}
)

expect(spans.map((exported) => exported.kind)).toEqual([SpanKind.PRODUCER, SpanKind.INTERNAL])
})

test('startPendingSpan() uses the same kind for the placeholder and the real span', async () => {
const spans = await collectSpans(
(primary) => [primary, new PendingSpanProcessor(primary)],
() => {
startPendingSpan('consume message', {}, { kind: SpanKind.CONSUMER }).end()
}
)

expect(spans.map((exported) => [exported.attributes[ATTRIBUTES_SPAN_TYPE_KEY], exported.kind])).toEqual([
['pending_span', SpanKind.CONSUMER],
['span', SpanKind.CONSUMER],
])
})

test('log helpers stay INTERNAL when a runtime options object carries a kind', async () => {
const spans = await collectSpans(
(primary) => [primary],
() => {
const sneakyOptions: SpanOptions = { kind: SpanKind.CLIENT }
info('plain log', {}, sneakyOptions)
}
)

expect(spans.map((exported) => [exported.attributes[ATTRIBUTES_SPAN_TYPE_KEY], exported.kind])).toEqual([['log', SpanKind.INTERNAL]])
})

test('instrument() exports the provided kind on the call span', async () => {
const spans = await collectSpans(
(primary) => [primary],
() => {
const fetchRecords = instrument(() => 42, { kind: SpanKind.CLIENT })
expect(fetchRecords()).toBe(42)
}
)

expect(spans.map((exported) => exported.kind)).toEqual([SpanKind.CLIENT])
})
})
24 changes: 3 additions & 21 deletions packages/logfire-api/src/startPendingSpan.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,13 @@
import type { ReadableSpan, SpanProcessor } from '@opentelemetry/sdk-trace-base'
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'

import { trace as TraceAPI } from '@opentelemetry/api'
import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'
import { describe, expect, test } from 'vite-plus/test'

import { collectSpans } from './__test__/collectSpans'
import { ATTRIBUTES_SPAN_TYPE_KEY } from './constants'
import { configureLogfireApi, startPendingSpan } from './index'
import { startPendingSpan } from './index'
import { PendingSpanProcessor } from './PendingSpanProcessor'
import { TailSamplingProcessor } from './TailSamplingProcessor'

async function collectSpans(createProcessors: (primary: SpanProcessor) => SpanProcessor[], run: () => void): Promise<ReadableSpan[]> {
const exporter = new InMemorySpanExporter()
const primary = new SimpleSpanProcessor(exporter)
const provider = new BasicTracerProvider({ spanProcessors: createProcessors(primary) })
TraceAPI.setGlobalTracerProvider(provider)
configureLogfireApi({ otelScope: 'logfire', scrubbing: false })

try {
run()
await provider.forceFlush()
return exporter.getFinishedSpans()
} finally {
await provider.shutdown()
TraceAPI.disable()
}
}

function pendingSpans(spans: ReadableSpan[]): ReadableSpan[] {
return spans.filter((span) => span.attributes[ATTRIBUTES_SPAN_TYPE_KEY] === 'pending_span')
}
Expand Down
Loading