OpenTelemetry
otelMiddleware 팩토리는 TanStack AI를 기존 OpenTelemetry 설정에 연결합니다. 모든 chat() 호출은 루트 스팬 하나, 프로바이더 모델 호출마다 자식 스팬 하나(에이전트 루프 차례 또는 구조화된 출력 최종화), 도구 호출마다 손자 스팬 하나를 생성하며, 모두 GenAI 시맨틱 규칙 속성을 포함합니다. Meter가 제공되면 GenAI 토큰 및 기간 히스토그램도 기록합니다.
도구가 없는 구조화된 출력 호출은 에이전트 루프를 건너뛰고 최종화 요청만 실행합니다. 이 경로에서도 반복 스팬을 엽니다(structuredOutput 미들웨어 단계 사용). 따라서 생성 스팬을 기준으로 동작하는 백엔드(예: PostHog $ai_generation)와 captureContent가 모두 작동합니다. 네이티브 결합 모드(supportsCombinedToolsAndSchema)에서는 해당 단계가 실행되지 않으며, 단일 beforeModel 스팬이 결합 호출 전체를 포괄합니다.
설정
@opentelemetry/api를 설치합니다. 이는 @tanstack/ai의 선택적 peer dependency입니다.
pnpm add @opentelemetry/api
기존 방식대로 OTel SDK를 연결합니다(예: @opentelemetry/sdk-node). 그런 다음 Tracer(선택적으로 Meter)를 미들웨어에 전달합니다. OTel 미들웨어는 별도의 서브패스에 있으므로, OTel이 필요하지 않은 사용자는 이를 가져와도 영향을 받지 않습니다.
import { chat } from '@tanstack/ai'
import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
import { openaiText } from '@tanstack/ai-openai'
import { trace, metrics } from '@opentelemetry/api'
const otel = otelMiddleware({
tracer: trace.getTracer('my-app'),
meter: metrics.getMeter('my-app'),
})
const result = await chat({
adapter: openaiText('gpt-5.5'),
messages: [{ role: 'user', content: 'hi' }],
middleware: [otel],
stream: false,
})
생성되는 항목
스팬
chat gpt-5.5 (root, kind: INTERNAL)
├── chat gpt-5.5 #0 (iteration, kind: CLIENT)
│ ├── execute_tool get_weather
│ └── execute_tool get_time
└── chat gpt-5.5 #1 (iteration, kind: CLIENT)
반복 스팬은 모델 호출이 관찰되는 순서대로 번호가 매겨집니다(#0, #1, ...). 따라서 같은 채팅에서 프로바이더와 주고받은 각각의 왕복을 트레이스 뷰어에서 쉽게 구분할 수 있습니다.
속성 참조
| 수준 | 속성 | 값 |
|---|---|---|
| 루트 / 반복 | gen_ai.system | openai, anthropic, ... |
| 반복 | gen_ai.operation.name | chat |
| 루트 / 반복 | gen_ai.request.model | 요청된 모델 |
| 반복 | gen_ai.response.model | 실제 모델 |
| 반복 | gen_ai.request.temperature | 설정에서 가져옴 |
| 반복 | gen_ai.request.top_p | 설정에서 가져옴 |
| 반복 | gen_ai.request.max_tokens | 설정에서 가져옴 |
| 반복 | gen_ai.usage.input_tokens | 반복별 |
| 반복 | gen_ai.usage.output_tokens | 반복별 |
| 루트 / 반복 | gen_ai.usage.total_tokens | 프로바이더가 보고한 총량 |
| 루트 / 반복 | gen_ai.usage.cost | 가능한 경우 프로바이더가 보고한 비용 |
| 루트 / 반복 | gen_ai.usage.cache_read.input_tokens | 보고된 경우 캐시된 프롬프트 토큰 |
| 루트 / 반복 | gen_ai.usage.cache_creation.input_tokens | 보고된 경우 캐시 쓰기 프롬프트 토큰 |
| 루트 / 반복 | gen_ai.usage.reasoning.output_tokens | 보고된 경우 추론/사고 토큰 |
| 루트 / 반복 | tanstack.ai.usage.billed_quantity | 보고된 경우 토큰이 아닌 청구 수량 |
| 루트 / 반복 | tanstack.ai.usage.billed_unit | 청구 수량의 단위(seconds, units, ...) |
| 루트 / 반복 | tanstack.ai.usage.duration_seconds | 더 이상 권장되지 않는 기간 수입니다. 대신 billed_quantity/billed_unit을 읽습니다 |
| 루트 / 반복 | tanstack.ai.usage.units_billed | 더 이상 권장되지 않는 단위 수입니다. 대신 billed_quantity/billed_unit을 읽습니다 |
| 루트 / 반복 | tanstack.ai.usage.upstream_cost | 보고된 경우 게이트웨이 업스트림 비용(예: OpenRouter) |
| 루트 / 반복 | tanstack.ai.usage.upstream_input_cost | 보고된 경우 업스트림 입력 비용 분할 |
| 루트 / 반복 | tanstack.ai.usage.upstream_output_cost | 보고된 경우 업스트림 출력 비용 분할 |
| 반복 | gen_ai.response.finish_reasons | [stop], [tool_calls], ... |
| 루트 | gen_ai.usage.input_tokens | 합산됨 |
| 루트 | gen_ai.usage.output_tokens | 합산됨 |
| 루트 | tanstack.ai.iterations | 반복 횟수 |
| 도구 | gen_ai.tool.name | 도구 이름 |
| 도구 | gen_ai.tool.call.id | 도구 호출 ID |
| 도구 | gen_ai.tool.type | function |
| 도구 | tanstack.ai.tool.outcome | success / error |
입력/출력 토큰 이외의 사용량 속성은 프로바이더가 보고한 경우에만 생성되므로, 그렇지 않을 때 스팬은 불필요하게 복잡해지지 않습니다. 캐시 및 추론 세부 내역은 공식 GenAI semconv 이름을 사용합니다. gen_ai.usage.cost와 gen_ai.usage.total_tokens는 PostHog 같은 백엔드가 직접 사용하는 사실상의 확장입니다. 이 값이 없으면 백엔드는 자체 가격표로 비용을 다시 계산해야 하며 캐시 할인과 게이트웨이 마크업을 잃게 됩니다. 확립된 규칙이 없는 필드(청구 수량/단위 쌍, 업스트림 비용 분할, 더 이상 권장되지 않는 단순 수량)는 TanStack 네임스페이스를 사용합니다.
토큰이 아닌 청구(동영상 또는 전사의 초 단위, fal 엔드포인트 단위 등)에서는 tanstack.ai.usage.billed_quantity와 tanstack.ai.usage.billed_unit이 usage.billed에서 쌍으로 생성됩니다. 따라서 백엔드는 프로바이더를 알지 못해도 미디어 사용량에 라벨을 지정하고 집계할 수 있습니다. 더 이상 권장되지 않는 duration_seconds / units_billed 속성은 단위 없이 같은 수량을 전달하며 하위 호환성을 위해 계속 생성됩니다.
메트릭
GenAI 표준 히스토그램 두 개입니다.
gen_ai.client.operation.duration(초) — 모든 에이전트 루프 반복과 도구 실행을 포함하며chat()호출마다 한 번 기록됩니다. 오류 또는 중단 시 레코드에는error.type속성이 포함됩니다(발생한 오류의name또는 중단 시"cancelled").gen_ai.client.token.usage(토큰) — 반복마다 한 번 기록됩니다(입력과 출력, 두 레코드).gen_ai.token.type으로 태그됩니다.
gen_ai.response.id와 gen_ai.response.model은 카디널리티를 낮게 유지하기 위해 의도적으로 메트릭 속성에서 제외됩니다(요청별 사용자 지정 모델 이름과 요청 ID를 포함하면 시계열 집합이 지나치게 커집니다).
개인정보 보호: 프롬프트와 완료 내용 캡처
기본적으로 스팬에는 메타데이터만 기록됩니다. 프롬프트와 완료 내용을 기록하려면 captureContent: true로 설정합니다. 콘텐츠는 GenAI 규칙에 따라 OTel 스팬 이벤트로 캡처됩니다.
gen_ai.user.message,gen_ai.system.message,gen_ai.assistant.message,gen_ai.tool.message,gen_ai.choice
무엇이든 기록하기 전에 개인 식별 정보(PII)를 제거하려면 redact 함수를 전달합니다.
import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
import { trace } from '@opentelemetry/api'
const tracer = trace.getTracer('my-app')
otelMiddleware({
tracer,
captureContent: true,
redact: (text) => text.replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[SSN]'),
})
redact가 예외를 발생시키면 미들웨어는 스팬 이벤트에 리터럴 센티널 "[redaction_failed]"를 기록하고 경고를 로그에 남깁니다. 원본 콘텐츠로 대체하지 않습니다. 이는 트레이스를 서드파티 백엔드로 전송하는 사용자에게 중요한 불변 조건입니다. 고장 난 리댁터는 프롬프트를 유출하지 않고 캡처를 중단해야 합니다.
누적된 어시스턴트 텍스트(gen_ai.choice 이벤트)는 maxContentLength자(기본값 100 000)로 제한됩니다. 더 긴 완료 내용은 끝에 "…" 표시를 붙여 잘립니다.
멀티모달 콘텐츠(이미지, 오디오, 동영상, 문서)는 플레이스홀더 문자열([image], [audio], ...)로 표현됩니다. 바이너리 데이터를 스팬에 쏟아내지 않고 메시지 순서를 유지하기 위한 방식입니다. 더 풍부한 멀티모달 캡처가 필요하면 onSpanEnd를 사용합니다.
프롬프트/시스템/사용자 메시지 이벤트는 모든 반복의 시작 시 onConfig에서 실행됩니다. 따라서 전체 대화 기록(어댑터가 다시 전송할 내용)이 각 반복 스팬에 다시 생성됩니다. 이는 프로바이더가 실제 네트워크에서 보는 내용을 반영합니다.
확장 포인트
네 가지 확장은 모두 선택 사항입니다. 각각 사용자 코드를 try/catch로 감싸므로 콜백에서 발생한 예외는 로그 한 줄이 되며 채팅을 중단시키지 않습니다.
spanNameFormatter(info)
기본 스팬 이름을 재정의합니다. info.kind는 'chat' | 'iteration' | 'tool'입니다.
import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
import { trace } from '@opentelemetry/api'
const tracer = trace.getTracer('my-app')
otelMiddleware({
tracer,
spanNameFormatter: (info) =>
info.kind === 'tool' ? `tool:${info.toolName}` : `chat:${info.ctx.model}`,
})
attributeEnricher(info)
모든 스팬에 사용자 지정 속성을 추가합니다. 스팬마다 한 번 실행됩니다.
import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
import { trace } from '@opentelemetry/api'
import { getCurrentTenant } from './context'
const tracer = trace.getTracer('my-app')
otelMiddleware({
tracer,
attributeEnricher: () => ({
'tenant.id': getCurrentTenant(),
}),
})
onBeforeSpanStart(info, options)
tracer.startSpan(...) 직전에 SpanOptions를 변경합니다. 링크, 사용자 지정 시작 시간 또는 추가 기본 속성을 지정할 때 유용합니다.
onSpanEnd(info, span)
모든 span.end() 직전에 실행됩니다. 일반적인 용도는 사용자 지정 이벤트를 기록하거나 자체 Meter를 통해 도구별 메트릭을 생성하는 것입니다.
import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
import { trace, metrics } from '@opentelemetry/api'
const tracer = trace.getTracer('my-app')
const meter = metrics.getMeter('my-app')
const toolDuration = meter.createHistogram('tool.duration')
otelMiddleware({
tracer,
onSpanEnd: (info, span) => {
if (info.kind === 'tool') {
// span is still recording; read timestamps from your own store if needed
toolDuration.record(1, { 'tool.name': info.toolName })
}
},
})
채팅 외: 미디어 활동
otelMiddleware는 채팅 전용이 아닙니다. 미디어 활동인 generateImage, generateVideo, generateAudio, generateSpeech, generateTranscription은 middleware 옵션에 동일한 otelMiddleware 값을 받을 수 있습니다. 각각 단일 요청 → 응답(동영상의 경우 제출 → 폴링)이므로 미들웨어는 채팅 스팬 트리 대신 호출마다 스팬 하나를 생성합니다.
import { generateImage } from '@tanstack/ai'
import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
import { openaiImage } from '@tanstack/ai-openai'
import { trace, metrics } from '@opentelemetry/api'
const otel = otelMiddleware({
tracer: trace.getTracer('my-app'),
meter: metrics.getMeter('my-app'),
})
const result = await generateImage({
adapter: openaiImage('gpt-image-2'),
prompt: 'A serene mountain landscape at sunset',
middleware: [otel],
})
동일한 otel 값을 chat()과 모든 미디어 활동에 전달할 수 있습니다. 공유 수명 주기 훅(onStart / onUsage / onFinish / onAbort / onError)은 활동에 종속되지 않는 GenerationMiddlewareContext를 대상으로 작성되므로 하나의 인스턴스가 모든 곳에서 작동합니다.
각 미디어 호출은 활동의 gen_ai.operation.name으로 태그된 CLIENT 스팬 하나를 생성합니다.
| 활동 | gen_ai.operation.name |
|---|---|
generateImage | image_generation |
generateVideo | video_generation |
generateAudio | audio_generation |
generateSpeech | text_to_speech |
generateTranscription | transcription |
summarize | summarize |
스팬은 시작 시 gen_ai.system과 gen_ai.request.model을 포함하고, 종료 시 위에서 설명한 것과 동일한 gen_ai.usage.* / tanstack.ai.usage.* 속성을 포함합니다. 여기에는 단위로 청구되는 미디어를 위한 tanstack.ai.usage.billed_quantity / tanstack.ai.usage.billed_unit 쌍도 포함됩니다. Meter가 제공되면 활동별로 태그된 gen_ai.client.operation.duration 히스토그램을 기록합니다. 스트리밍 동영상의 경우 스팬은 전체 생성 → 폴링 → 완료 수명 주기를 포괄합니다. 비스트리밍 동영상은 두 번의 호출로 처리되므로 제출 자체는 스팬을 생성하지 않습니다. 프로바이더가 작업을 수락하면 실행이 시작되고, 종료 상태를 관찰하는 getVideoJobStatus() 폴링이 실행을 종료합니다. 스트리밍 동영상 소비자가 완료 전에 스트림을 중단하면 스팬은 유출되지 않고 onAbort를 통해 종료됩니다(상태 ERROR, tanstack.ai.completion.reason = cancelled).
otelMiddleware는 미디어 스팬에도 동일한 spanNameFormatter, attributeEnricher, onBeforeSpanStart, onSpanEnd 확장 지점을 적용합니다. 스팬 정보는 kind로 구분되며 미디어 스팬은 kind: 'generation'을 보고합니다. 사용자 지정 백엔드에서는 기본 GenerationMiddleware 계약을 직접 구현합니다. 해당 훅(onStart / onUsage / onFinish / onAbort / onError)은 GenerationMiddlewareContext를 받고 채팅을 포함한 모든 활동에서 실행됩니다. GenerationMiddleware 타입은 패키지 루트에서 내보내고, otelMiddleware 값은 @tanstack/ai/middlewares/otel 서브패스에 있으므로 @tanstack/ai를 가져와도 선택적 @opentelemetry/api peer가 필요하지 않습니다.