☰
FastAPI分布式追踪:Span与Scope核心原理与OpenTelemetry实践
2026/10/14 3:49:31 网站建设 项目流程

在微服务架构里排查一次慢请求,最让人头疼的就是“日志都有,就是串不起来”。尤其是用FastAPI搭了十几个服务之后,某个接口偶发超时,单看每个服务日志都正常,可你根本不知道时间到底耗在哪个环节、哪次调用掉了链子。这时候分布式追踪就不是锦上添花了,而是刚需。

FastAPI的异步特性加上Python的GIL和事件循环,让它和传统的同步WSGI框架在追踪实现上有不少微妙的差异。很多人初次接触OpenTelemetry的时候,被Span、Scope、Context这几个概念绕得云里雾里,代码照着文档抄能跑,但一旦需要手动埋点、自定义传播逻辑,或者排查追踪数据丢失的问题,就完全无从下手。

这篇内容专门把Span和Scope讲透,结合FastAPI的实际场景,从核心原理到可落地的代码,再到常见的坑,一次性说清楚。适合已经会用FastAPI写接口、想上分布式追踪但还没完全理解底层机制的后端开发者。

1. 内容整体设计与思路拆解

1.1 为什么分布式追踪的核心是Span而不是别的

分布式追踪要回答的问题其实很朴素:一次用户请求,在系统里到底经历了什么。为了回答这个问题,需要一种数据结构来记录“某一次具体操作”的起止时间、属性、状态和层级关系,这个数据结构就是Span。

一个Span代表一个命名的时间片段,比如“查询用户信息”“调用订单服务”“执行数据库查询”。每个Span都有自己的Trace ID(一次完整请求的全局唯一编号)和Span ID(单个操作片段的唯一编号)。通过Span之间的父子引用关系,就能还原出一棵完整的调用树。

理解了Span,分布式追踪的大半个骨架就搭起来了。那剩下的Scope和Context又是什么?这就要说到分布式追踪需要解决的另一个问题:如何让一个请求在处理过程中,所有代码都能自动拿到当前活跃的Span。这才是Scope存在的真正价值。

1.2 Scope和Context:让Span“隐形地”贯穿全程

实际写业务代码的时候,你不希望每个函数都把Span对象传来传去。比如一个下单接口,里面调用了库存服务、支付服务、优惠券服务,如果每个函数都要手动接收一个span参数,代码会变得非常丑陋。

Context是一个存储载体,里面装着当前请求的Trace ID、Span ID等信息。在OpenTelemetry的设计里,Context是隐式传递的——它不通过函数参数传递,而是通过语言的上下文机制(比如Python的contextvars)保存在当前执行环境中。Scope则是“管理当前活跃Span的句柄”,它负责把某个Span设定为当前上下文中的活跃对象,并在操作结束后恢复原状。

用生活化的类比来说:Context就像你手上的一份快递单,上面写着包裹编号和当前环节的扫描记录;Scope则是你手里的扫码枪,当你扫一个包裹时,这个包裹就成了“当前正在处理的包裹”,处理完再换下一个。Span是包裹本身,Scope是那个“当前活跃”的状态管理。

1.3 OpenTelemetry作为实现方案的选择理由

提到分布式追踪,业界有OpenTracing、OpenCensus、OpenTelemetry好几代规范,现在基本已经收敛到OpenTelemetry一家。选择它作为FastAPI分布式追踪的实现基础,有几个非常实际的考量。

第一,OpenTelemetry是CNCF孵化的项目,生态整合能力最强。FastAPI官方维护了一个opentelemetry-instrumentation-fastapi库,只要两行代码就能给所有路由自动创建Span,这个便利性是其他方案比不了的。

第二,它统一了Trace和Metrics的API,后续想从追踪扩展到监控告警体系,不需要推翻重来。

第三,OpenTelemetry的Span模型就是前文说的父-子树形结构,配合Scope和Context的明确职责划分,概念清晰,出现问题的时候排查路径也比较直观。

2. 核心细节解析与实操要点

2.1 Span的完整生命周期状态机

一个Span从创建到结束,会经历Pending、Active、Ended等状态切换。理解这个生命周期,才能理解很多API设计的合理性。

创建Span时,它处于“未结束”状态,此时可以给它添加属性(Attribute)、事件(Event)、状态(Status)。调用span.end()之后,Span被标记为结束,数据才有资格被导出到后端(比如Jaeger或Zipkin)。这一点很重要:忘记调用end()的Span,要么在超时后被强制结束,要么就永远不会出现在追踪结果里。

在实际操作中,我用with语句管理Span的生命周期,这是最不容易出错的方式:

from opentelemetry import trace tracer = trace.get_tracer("my-service") def handle_order(order_id: str): with tracer.start_as_current_span("process_order") as span: span.set_attribute("order.id", order_id) # 业务逻辑 ... # 出了with块,span自动end()

start_as_current_span这个API是Scope机制的核心入口。它做了三件事:创建Span、把Span设为当前活跃Span、在with块结束时结束Span并恢复之前的上下文。如果只想要一个Span但不想让它成为当前活跃的(比如启动一个异步后台任务,不希望它污染主流程的上下文),就用tracer.start_span()。

2.2 Scope的嵌套与恢复机制

Scope一个容易被忽略但极其重要的特性是:它是可以嵌套的,并且在退出时会精确恢复上一层状态。这一点对理解“为什么我的Span串了”非常关键。

看一个典型的场景:A接口调B服务,B服务内部又并行调了C和D。如果处理不当,C和D的Span可能都会被记录成A的直接子Span,但实际上C和D是B的子Span。要保证这一点,就要确保在发起C和D调用之前,B的Span还是当前活跃的。

OpenTelemetry的Scope用栈式管理解决这个问题。每次start_as_current_span就像往栈顶压入一个新的活跃Span,退出with块时弹出并恢复上一个活跃Span。所以只要你的子调用都发生在with块内部,嵌套关系就不会乱。

这里有一个FastAPI场景下必踩的坑:异步任务里的Scope管理。在事件循环里,如果使用了asyncio.gather并发执行多个任务,每个任务内部的start_as_current_span必须确保运行在同一个contextvars.Context中。Python的contextvars在asyncio中天然支持任务隔离,这在多数情况下没问题,但如果你用threading混入异步代码,就得手动传递Context了。

2.3 FastAPI中自动埋点与手动埋点的分工

FastAPI集成OpenTelemetry有两种层级的埋点方式,灵活组合才是最佳实践。

自动埋点由opentelemetry-instrumentation-fastapi实现。它会为每个HTTP请求自动创建一个Span,名字通常是路由路径模板,比如GET /orders/{order_id},并把HTTP方法、状态码、客户端IP、请求头等作为Span属性记录下来。这一步的覆盖面最广,所有接口的入口出口信息都能拿到,但它的粒度只到“请求”级,无法告诉你请求内部的业务逻辑调了哪些函数、执行了多少次SQL。

手动埋点则负责“把链路伸进业务内部”。我通常在上面的handle_order这类关键业务方法上手动加Span,把订单号、用户ID、操作结果这些业务语义属性写进去。

两种方式结合的效果:自动埋点保证“骨架完整”,手动埋点保证“肌肉丰满”。追踪系统最终呈现的就是一棵既有HTTP层节点、又有业务方法的调用树,排查问题的时候能定位到具体函数而不是只看到接口入口。

2.4 传播机制:跨服务传递的隐形桥梁

单服务内的追踪意义有限,真正体现分布式追踪价值的是跨服务的串联。而这依赖Propagator——也就是同一个Trace ID如何从一个服务传到下一个服务。

OpenTelemetry默认使用W3C Trace Context标准,通过HTTP头traceparent和tracestate传递追踪上下文。当服务A调用服务B时,A会把当前Trace ID和Span ID写入traceparent头,B收到请求后从HTTP头中提取这些信息,作为自己创建Span时的父级上下文。

在FastAPI里做HTTP客户端调用时要注意一点:只有通过opentelemetry-instrumentation-httpx或opentelemetry-instrumentation-requests这些自动埋点库发起的请求,才会自动注入传播头。如果你用原始的httpx.AsyncClient自己写请求,没有打补丁,那传播头就带不过去,链路会在服务A和服务B之间断裂。

3. 实操过程与核心环节实现

3.1 环境准备:安装与初始化

我用uv管理Python项目依赖,方便快捷:

uv add opentelemetry-api opentelemetry-sdk opentelemetry-instrumentation-fastapi opentelemetry-instrumentation-httpx opentelemetry-exporter-otlp-proto-http

安装完成后,需要先配置TracerProvider和导出器。以导出到Jaeger为例,我习惯把端点地址做成环境变量,方便不同环境切换:

# tracing_config.py from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.resources import Resource, SERVICE_NAME import os def setup_tracing(service_name: str): resource = Resource(attributes={ SERVICE_NAME: service_name }) provider = TracerProvider(resource=resource) provider.add_span_processor( BatchSpanProcessor( OTLPSpanExporter( endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://jaeger:4318/v1/traces") ) ) ) trace.set_tracer_provider(provider) return trace.get_tracer(service_name)

这段代码最核心的配置有两个。一个是Resource里的SERVICE_NAME,它决定了在追踪系统里这个服务叫什么名字,不设置的话会默认叫unknown_service,到时候一屏的unknown_service根本分不清谁是谁。另一个是BatchSpanProcessor,它把Span在内存里攒一批再导出,避免每个请求都同步阻塞等待网络IO。

3.2 启动服务并接入自动追踪

完成了基础配置,接下来把它挂到FastAPI应用上。最关键的一步是在创建FastAPI实例之后立即调用Instrumentor().instrument(),而且必须在应用接收请求之前完成,否则最先进入的请求会漏掉自动Span。

# main.py from fastapi import FastAPI from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor from tracing_config import setup_tracing setup_tracing("order-service") app = FastAPI() FastAPIInstrumentor.instrument_app(app) @app.get("/health") async def health(): return {"status": "ok"}

这一版跑起来之后,你就能在Jaeger里看到HTTP请求的Span了。但它还比较“裸”——没有业务语义,也没有数据库查询等内部信息。接下来要往里加肉。

3.3 在业务层手动埋点:细化调用链

假设业务里有一个下单接口,我需要单独追踪“校验库存”和“创建订单”这两个子操作,并且要把订单ID和用户ID记录到Span上。手动埋点的代码是这样的:

from opentelemetry import trace tracer = trace.get_tracer("order-service.business") async def place_order(user_id: str, order_id: str): with tracer.start_as_current_span("place_order") as order_span: order_span.set_attribute("user.id", user_id) order_span.set_attribute("order.id", order_id) # 追踪校验库存的内部耗时 with tracer.start_as_current_span("check_stock") as stock_span: stock_span.set_attribute("order.id", order_id) stock_result = await check_stock(order_id) stock_span.set_attribute("stock.available", stock_result) # 追踪创建订单的内部耗时 with tracer.start_as_current_span("create_order_record") as create_span: create_span.set_attribute("order.id", order_id) await save_order_to_db(order_id, user_id) order_span.set_status(trace.StatusCode.OK) return order_id

用with语句嵌套管理多个子Span,代码完全不用手动关闭Span,也不用关心当前活跃Span的切换,Scope机制全都处理了。嵌套结构也清清楚楚:place_order是父Span,check_stock和create_order_record是它的子Span。

实操中的一点心得:业务属性不要一股脑全塞到Span上。每一条Attribute都有存储和传输成本,我只记录那些“排查问题时一定能用上”的信息,比如订单ID、用户ID、下游服务的返回码,而像请求体里的完整JSON这种,绝不写入Span。

3.4 线程与异步场景的Scope传播

FastAPI开发者最常踩的坑之一,就是在线程池里丢上下文。比如你用了run_in_executor把一个阻塞操作丢到线程池里,如果没有显式传递Context,那个线程里的start_as_current_span创建的Span就不知道自己的父亲是谁,链路就断了。

解决思路是利用OpenTelemetry提供的context库手动绑定上下文:

import asyncio from opentelemetry import context, trace from opentelemetry.context import attach, detach tracer = trace.get_tracer("order-service") async def run_with_context(): current_ctx = context.get_current() loop = asyncio.get_running_loop() def blocking_task(): # 在线程中恢复主协程的上下文 token = attach(current_ctx) try: with tracer.start_as_current_span("threaded_task") as span: span.set_attribute("task.type", "blocking") # 实际的阻塞操作 ... finally: detach(token) await loop.run_in_executor(None, blocking_task)

这段代码的关键在attach(current_ctx):它把主协程里的透传上下文(包括Trace ID和Span ID)绑定到当前线程的局部存储里。这样线程里创建的Span才能正确地挂到父Span下面。处理完任务后要detach恢复,否则线程复用时可能串上下文。

3.5 完整的传播链路验证

服务端到端的传播验证,我一般会起两个FastAPI服务,让A调用B,然后在Jaeger里看是否形成一条完整的链路。

服务A的某个接口里用httpx发起对服务B的调用:

import httpx from opentelemetry import trace from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor # 在应用启动时对httpx打补丁 HTTPXClientInstrumentor().instrument() tracer = trace.get_tracer("service-a") async def call_service_b(order_id: str): with tracer.start_as_current_span("call_service_b") as span: span.set_attribute("order.id", order_id) async with httpx.AsyncClient() as client: response = await client.post( "http://service-b:8001/internal/orders", json={"order_id": order_id}, ) span.set_attribute("http.status_code", response.status_code) return response.json()

只要HTTPXClientInstrumentor().instrument()执行了,httpx在发请求时就会自动从当前Context取出Trace信息,写进HTTP头。服务B收到请求时,FastAPIInstrumentor会自动读取HTTP头里的traceparent,把新创建的Span挂到同一棵树上。

验证时我习惯先故意不打httpx的补丁跑一次,再看看Jaeger里的图——链路会从服务A的一个Span直接断掉,B的Span变成了一个根Span。看了这个断裂的效果,你就彻底明白Propagator是干什么的了。

4. 工具选型解析与配置参数详解

4.1 导出器的选择:OTLP、Jaeger还是Zipkin

OpenTelemetry的架构把数据生产(SDK)和数据展示(后端)解耦了,导出器就是中间那根管子。当前主流的选择有OTLP、Jaeger和Zipkin。

OTLP是OpenTelemetry自己的原生协议,既能导到Jaeger也能导到其他OTLP兼容后端。Jaeger现在的版本已经原生支持OTLP接收,所以我不再单独用Jaeger exporter,直接统一走OTLP。

Zipkin的导出器现在已经标记为废弃状态,官方推荐改用OTLP。如果你是非要用Zipkin的老项目,建议尽早迁移到Jaeger或者结合OTLP的方案,避免后续升级OpenTelemetry SDK的时候遇到兼容性问题。

对比来看:

方案协议后端要求适用场景
OTLP over HTTPOTLP/HTTPJaeger、Collector等默认首选
OTLP over gRPCOTLP/gRPC需要gRPC端点支持性能敏感、吞吐量大
Jaeger exporterThriftJaeger专用老版本兼容
Zipkin exporterZipkin V2Zipkin或兼容服务不建议新项目使用

日常开发调试阶段,我还会额外加一个控制台导出器,让Span在终端直接打印,快速看效果而不用打开Jaeger:

from opentelemetry.sdk.trace.export import ConsoleSpanExporter provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))

务环境里绝不开启控制台导出器,打印全量Span会把日志系统冲垮。

4.2 采样策略:别把所有请求都记录下来

分布式追踪的原始数据量极大,如果线上的QPS是1000,每个请求平均产生5个Span,一秒钟就是5000条记录。通通落库的成本不低,所以采样策略是必须考虑的环节。

OpenTelemetry提供了几种采样器。ParentBased是我最推荐的一类,它的设计思路是:如果父级上下文决定采样,那子Span直接继承这个决定;如果当前是根Span(没有父级),则由配置的根采样器决定。这样就保证了“要么整条链路都在,要么整条链路都不在”,不会出现一个Trace里只有一半Span的情况。

一个比较省心的配置是ParentBased配合TraceIdRatioBased。比如线上允许10%的采样率:

from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased provider = TracerProvider( resource=resource, sampler=ParentBased(root_sampler=TraceIdRatioBased(0.1)) )

TraceIdRatioBased(0.1)的意思是:对于没有父Span的根请求,有10%的概率被采样。一旦根请求被采样,它的所有子请求也会被采样,这就保证了链路的完整性。

实操中我建议另外记住一点:像/health、/metrics这类探活接口,就算采样率再低,在高频调用下也会产生大量Span。我通常会在自动埋点之后,额外加一个Filter或者直接在业务层跳过这些路由的Span记录,省掉这部分数据噪音。

4.3 Span的Attribute命名与记录规范

打开Jaeger看Span的时候,最痛苦的就是一堆乱七八糟的属性名:有的叫userID,有的叫user_id,有的直接叫uid。统一的命名规范不是形式主义,它直接决定了追踪数据的实际可用性。

我个人的规范是:属性名一律小写下划线,第一个单词是领域对象,后面跟具体属性,例如user.id、order.amount、db.system、http.status_code。OpenTelemetry自身有一套语义约定,比如HTTP类的属性统一用http.method、http.route、http.status_code。遵循这套约定最大的好处是,不用自己设计一套,社区已有的查询面板、告警规则都能直接复用。

属性值只写标量或短字符串。千万不要把完整的SQL语句、用户手机号、身份证这种敏感信息写进Span,这是追踪系统最常见的隐私事故源头。如果确实需要记录SQL,我习惯只记录操作类型和表名,例如db.operation=SELECT、db.table=orders。

span.set_attribute("db.operation", "SELECT") span.set_attribute("db.table", "orders")

5. 常见问题与排查技巧实录

5.1 问题一:Jaeger里看不到任何Span

信息孤岛排查顺序很重要。先看服务日志里有没有报错,再看配置。

最常见的几个原因:

  • TracerProvider没有被正确设置。调用trace.set_tracer_provider(provider)是必须的,只创建了provider但没有设置到全局,业务代码里的trace.get_tracer()就还是用的默认空实现,所有Span都会被丢弃。
  • 导出器端点地址不可达。用OTA端点的注意区分HTTP和gRPC的端口号,OTLP/HTTP默认是4318,OTLP/gRPC默认是4317,写错了数据就无声无息地丢了。
  • 服务还没产生任何被采样的请求。用了TraceIdRatioBased且采样率设成0.01的话,可能连续几十个请求都没被采样,这是正常的,不代表链路断。

定位技巧:开发环境开ConsoleSpanExporter,请求一次就能在控制台看到Span有没有被创建。控制台都看不到Span,那问题肯定在埋点层或TracerProvider配置;控制台能看到但Jaeger看不到,问题就在导出链路。

5.2 问题二:服务间链路断裂

服务A的Span是完整的,服务B也有独立的Span,但两边不在同一个Trace下。这就是传播断了。

按照这个顺序排查:

  • 检查HTTP客户端库是否已instrument。最常见的场景是httpx和requests互换了,或者用了一个没打补丁的自定义Client。
  • 检查服务B的入口是否被FastAPIInstrumentor处理。B如果是FastAPI服务,确认FastAPIInstrumentor.instrument_app(app)执行了。
  • 检查是否有中间件或代理剥掉了traceparent头。某些网关或自定义中间件只转发白名单头,需要确认traceparent没有被过滤。
  • 如果服务之间走的是消息队列(比如Kafka、RabbitMQ),那要检查消息的Propagator是否配置了W3CTraceContextPropagator,并且消息的生产者和消费者都要做承载Context的注入与提取。

一个调试小技巧:在服务A的发包处打印traceparent的内容,到服务B的入口处打印接收到的请求头。对比两个值,就能立刻知道是写入端没写,还是传输中被改了,还是读取端没读。

# 在浏览器或后端打印traceparent from opentelemetry import trace current_span = trace.get_current_span() ctx = trace.format_span_id(current_span.get_span_context().span_id) print(f"trace_id: {trace.format_trace_id(current_span.get_span_context().trace_id)}")

5.3 问题三:异步任务里丢失上下文

在FastAPI里用BackgroundTasks或者asyncio.create_task发起后台任务,有时候追踪数据会丢。关键在于:新创建的任务虽然会复制当前Context,但如果在任务内部你用了tracer.start_span()而不是start_as_current_span(),那么这个Span不会自动成为当前活跃Span,它创建的祖父孙关系会断掉。

还有一种情况是跨线程。ThreadPoolExecutor里的线程不会自动继承协程的contextvars,一定要用前面讲到的attach和detach手动绑定。它远没有达到“零配置”的程度,踩一次坑之后记住就好。

5.4 问题四:Span太多查询太慢

Span数量爆炸通常来自两类情况:一是全量采样,二是循环代码里创建了海量Span。

循环里创建Span要有节制:

# 错误示范:每个订单项都创建一个子Span for item in order_items: with tracer.start_as_current_span(f"process_item_{item.id}"): ... # 改良思路:一个循环只创建一个Span,把处理数量作为属性记录 with tracer.start_as_current_span("process_order_items") as span: span.set_attribute("item.count", len(order_items)) for item in order_items: ...

另一个手段是前面提到的采样率调整。线上环境我习惯先全量采样跑几天,摸清流量模型之后,把常规业务接口的采样率降到10%以下,只对核心下单链路保留100%采样。做法是给不同接口分配不同的Tracer,或者用采样器里基于http.route的自定义逻辑。

5.5 问题五:Span时间不准

排查中发现某个Span的耗时明显异常,甚至超过实际时间,多半是Span没有被正确结束。比如代码里手动调了span.start()但忘了span.end(),或者异常路径没有走完with块,这个Span的结束时间就会拖到进程退出才被强制补上。

解决办法仍然是:能用with start_as_current_span就绝不用手动start/end。with语句在异常时也会执行__exit__,保证Span一定有始有终。在需要给整个函数记录耗时的场景,装饰器是更省心的选择:

from functools import wraps from opentelemetry import trace def traced(name: str): def decorator(func): @wraps(func) async def wrapper(*args, **kwargs): tracer = trace.get_tracer("business") with tracer.start_as_current_span(name) as span: return await func(*args, **kwargs) return wrapper return decorator @traced("process_payment") async def process_payment(payment_id: str): ...

用装饰器的好处是埋点和业务逻辑完全分离,除非有必要,否则业务代码里不要出现任何追踪相关代码。这样后期要调整埋点范围,改一处就可以了。

5.6 实操中的一些经验和避坑习惯

根据我自己的实践,整理几条值得长期遵守的规矩。

  • Span名称保持稳定。不要把一个Span命名为handler_123这种带ID的名字,而要用路由模板,比如GET /orders/{order_id}。稳定名称才能聚合查询,不然每个请求都是新名字,Jaeger的“服务依赖图”就没法看了。
  • 同步和异步代码分开对待。asyncio下创建的Span和线程里创建的Span,生命周期管理方式不同,混用时要特别小心上下文切换。
  • 监控一下导出器本身。OpenTelemetry SDK的导出失败会静默重试,如果Collector挂了一整天,你甚至不会发现数据已经丢了一天。给导出器加一个独立的指标,或者在关键Span里记录otel.status_code属性,定期检查导出失败率。
  • 不要把OpenTelemetry的采样器当成节流器。采样是为了控制成本,但如果一个高价值用户恰好不在采样范围内,你就丢失了一次珍贵的排障机会。对线上特定接口做全采样,比全局调高采样率更合理。
  • 日志和Trace关联一定要做。在日志里输出trace_id和span_id,这是排查生产事故时最快捷的路径。没有这层关联,手动翻日志和追踪系统两边对时间,效率会低很多。
import logging from opentelemetry import trace logger = logging.getLogger("order-service") def log_with_trace(level: int, msg: str): current_span = trace.get_current_span() span_context = current_span.get_span_context() extra = {"trace_id": trace.format_trace_id(span_context.trace_id), "span_id": trace.format_span_id(span_context.span_id)} logger.log(level, msg, extra=extra)

5.7 一个完整的迷你项目全景

最后用一个简短但完整的例子,把从初始化、埋点到传播的整个流程串起来,方便你做最小验证。

服务端:

from fastapi import FastAPI from opentelemetry import trace from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor from tracing_config import setup_tracing tracer = setup_tracing("order-service") app = FastAPI() FastAPIInstrumentor.instrument_app(app) @app.post("/orders") async def create_order(req: dict): order_id = req["order_id"] with tracer.start_as_current_span("create_order") as span: span.set_attribute("order.id", order_id) result = await process_payment(order_id) span.set_attribute("payment.result", result) return {"status": "ok", "order_id": order_id}

调用端:

import httpx from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor HTTPXClientInstrumentor().instrument() async def trigger(): async with httpx.AsyncClient() as client: resp = await client.post( "http://localhost:8000/orders", json={"order_id": "ORD-20250101-001"}, ) print(resp.json())

运行起来之后,控制台导出器会打印出完整的Span树结构,包括Trace ID、Span ID、父Span ID以及每个Span的起止时间。把导出的端点改为Jaeger的OTLP端点,就能在界面上看到可视化的调用链和Gantt图了。

我到现在还记得第一次在Jaeger里看到完整调用链时的感觉:那次线上的偶发超时,之前查了两天没结论,接上分布式追踪之后半个小时就锁定了是下游服务某个连接池打满导致的等待。工具的价值不在于它多新潮,而在于它能不能让你在故障现场快速看清事实。Span和Scope这两个基础概念,就是看清事实的起点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询