当今复杂动态系统的可观察性取决于领域知识,或者更重要的是,基于不完整的领域知识产生的未知“未知”。换句话说,落在裂缝之间的案例让我们感到惊讶,如以下引文所示:
我们花了不到一个小时就弄清楚了如何恢复网络;需要额外的几个小时,因为我们花了很长时间才控制行为异常的 IMP 并使它们恢复正常。内置的软件警报系统(当然,假设它不受误报的影响)可能使我们能够更快地恢复网络,从而显着缩短中断的持续时间。这并不是说更好的警报和控制系统可以替代试图正确分配重要资源利用的仔细研究和设计,而只是说它是一个必要的辅助手段,可以处理即使是最仔细的设计也不可避免地落在裂缝之间的情况
从本质上讲,可观察性是我们如何暴露系统的行为(希望以某种有纪律的方式)并理解这种行为。
在本文中,我们将讨论可观测性的重要性,并将研究如何编写可观察 Rust 应用程序的基础。
为什么可观测性很重要?
由于微服务部署和编排引擎的激增,我安卓手机自动亮度调节失灵怎么办?8个修复图文教程分享们的系统变得更加复杂,大公司运行着数千个微服务,甚至初创公司也在运营数百种微服务。
微服务的严酷现实是,它们突然迫使每个开发人员成为云/分布式系统工程师,处理分布式系统固有的复杂性。具体而言,部分故障,其中一个或多个服务的不可用可能会以未知方式对系统产生不利影响。–(Meiklejohn等人,服务级别故障注入测试)
在这个复杂的时代,实现可观测性对于长期构如何在Windows11上捕获屏幕截图?Windows11电脑截图方法大全建、故障排除和对系统进行基准测试大有帮助。提供可观测性首先从我们正在运行的系统收集输出数据(遥测和检测),在适当的抽象级别(通常围绕请求路径进行组织),以便我们可以探索和剖析数据模式并找到互相关。
在纸面上,这听起来很容易实现。我们收集了三个支柱(日志、指标和跟踪),然后就完成了。然而,这三个支柱本身只是位,而收集最有用的位并一起分析位的集合则最复杂。
形成正确的抽象是困难的部分。它可以是非常特定于领域的,并且依赖于为我们的系统行为构建一个模型,该模型可以接受更改并为意外做好准备。它涉及开发人员必须更多地参与如何生成和诊断其应用程序和系统中的事件。
到处抛出日志语句并收集每个可如何禁止Spotify开机自动启动?4种超简单的方法关闭它能的指标会失去长期价值并引起其他问题。我们需要公开和增强有意义的输出,以便数据关联成为可能。
毕竟这是一篇 Rust 文章,虽然 Rust 在构建时考虑了安全性、速度和效率,但公开系统行为并不是其创始原则之一。
我们如何使 Rust 应用程序更易于观察?
从第一原则开始,我们如何检测代码,收集有意义的跟踪信息,并派生数据以帮助我们探索未知的“未知”?如果一切都由事件驱动如何在Windows 11电脑上创建系统镜像备份?仅需8个步骤轻松搞定,并且我们有捕获一系列事件/操作的跟踪,包括请求/响应、数据库读/写和/或缓存未命中等,那么对于必须与外界通信以实现端到端可观测性的 Rust 应用程序来说,从无到有的诀窍是什么?构建块是什么样的?
可悲的是,这里不仅有一个技巧或银弹,尤其是在编写 Rust 服务时,这给开发人员留下了很多东西。首先,我们唯一可以真正依赖来理解和调试未知“未知”的是遥测数据,我们应该确保我们呈现有意义的上下文遥测数据(例如,可相关的字段,如、和)。其次,我们需要一种方法来探索该输出并将其跨系统和服务相关联。request_pathparent_span
trace_idcategory
subject
在这篇文章中,我们将主要关注收集和收集有意义的上下文输出数据,但我们还将讨论如何最好地连接到提供进一步处理、分析和可视如何自定义Windows终端,颜色、字体、背景图像?完整指南化的平台。幸运的是,核心工具可用于检测 Rust 程序以收集结构化事件数据并处理和发出跟踪信息,以实现异步和同步通信。
我们将重点介绍最标准和最灵活的框架,跟踪,它位于跨度、事件和订阅者周围,以及如何利用它的可组合性和可定制性。
然而,即使我们有一个广泛的框架,比如跟踪,帮助我们在 Rust 中编写可观察服务的基础,有意义的遥测并不是“开箱即用”或“免费掉出来的”。
在 Rust 中获得正确的抽象并不像在其他语言中那样简单。相反,一个健壮的应用程序必须建立在分层行为之上,所有这些行为都为知情的开发人员提供了示例性控制,但对于那些缺乏经验的人来说可能很麻烦。
我们将把问题空间分解为一系列可组合层,这些层在四个不同的行为单元上起作用:
-
存储上下文信息以备将来使用
-
使用上下文信息扩充结构化日志
-
通过检测和跨度持续时间派生指标
-
分布式跟踪的开放遥测互操作性
类似于关于基于属性的测试的原始 QuickCheck 论文如何依靠用户指定属性并为用户定义的类型提供实例,构建端到端可观察的 Rust 服务需要了解跟踪是如何生成的,如何指定和维护数据,以及随着应用程序的增长,哪些遥测是有意义的。在调试和/或探索不一致、部分故障和可疑性能特征时尤其如此。
跟踪收集将驱动本文示例中的所有内容,其中跨度和事件将成为我们将已知数量的完整图片联系在一起的镜头。我们将有日志,但我们会将它们视为结构化事件。我们将收集指标,但通过检测和跨度实现自动化,并将与OpenTelemetry兼容的跟踪数据导出到像Jaeger这样的分布式跟踪平台。
范围、事件和跟踪
在进入实现细节之前,让我们从一些我们需要熟悉的术语和概念开始,例如跨度、跟踪和事件。
跨越
跨度表示属于跟踪的操作或段,并充当分布式跟踪的主要构建基块。对于任何给定的请求,初始跨度(没有父级)称为根跨度。趣知笔记它通常表示为给定分布式跟踪的整个用户请求的端到端延迟。
还可以有后续子跨度,这些子跨度可以嵌套在其他不同的父跨度下。跨度的总执行时间包括在该跨度中花费的时间以及由其子项表示的整个子树。
下面是新请求有意压缩的父跨度日志的示例:
level=INFO span name="HTTP request" span=9008298766368774 parent_span=9008298766368773 span_event=new_span timestamp=2022-10-30T22:30:28.798564Z http.client_ip=127.0.0.1:61033 http.host=127.0.0.1:3030 http.method=POST http.route=/songs trace_id=b2b32ad7414392aedde4177572b3fea3
此跨度日志包含重要的信息和趣知笔记网站地图元数据,例如请求路径 ()、时间戳 ()、请求方法 () 和跟踪标识符(分别是 、 和)。我们将使用此信息来演示如何将跟踪从开始到完成绑定在一起。http.route2022-10-30T22:30:28.798564Z
http.methodspan
parent_span``trace_id
为什么叫跨度?Ben Sigelman是Google的Dapper追踪基础设施论文的作者,他在《The Span》简史中考虑了这些因素: 难以爱,难以杀死:
在代码本身中,API 感觉就像一个计时器
当将跟踪视为有向图时,数据结构看起来像一个节点或顶点
在结构化、多进程日志记录的上下文中(旁注:归根结底,这就是分布式跟踪),人们可能会将跨度视为两个事件
给定一个简单的时序图,很容易将这个概念称为持续时间或窗口
事件
事件表示时间上的单个操作,其中在执行某个任意程序期间发生了某些事情。与带外非结构化日志记录相比,我们将事件视为在给定跨度上下文中发生的摄取的核心单元,并使用键值字段进行结构化(类似于上面的跨度日志)。更准确地说,这些事件称为跨度事件:
level=INFO msg="finished processing vendor request" subject=vendor.response category=http.response vendor.status=200 vendor.response_headers="{\"content-type\": \"application/json\", \"vary\": \"Accept-Encoding, User-Agent\", \"transfer-encoding\": \"chunked\"}" vendor.url=http://localhost:8080/.well-known/jwks.json vendor.request_path=/.well-known/jwks.json target="application::middleware::logging" location="src/middleware/logging.rs:354" timestamp=2022-10-31T02:45:30.683888Z
我们的应用程序还可以具有在跨度上下文之外发生的任意结构化日志事件。例如,在启动时显示配置设置或监视刷新缓存的时间。
痕迹
跟踪是表示某些工作流(如服务器请求或项目的队列/流处理步骤)的跨度集合。本质上,迹线是跨度的有向无环图,其中连接跨度的边指示跨度与其父跨度之间的因果关系。
下面是在 Jaeger UI 中可视化的跟踪示例:
如果此应用程序是更大的分布式跟踪的一部分,我们将看到它嵌套在更大的父跨度中。
现在,有了这些术语,我们如何开始实现可观测性就绪的 Rust 应用程序的骨架?
组合多个追踪层以构建订阅者
跟踪框架被拆分为不同的组件(作为板条箱)。出于我们的目的,我们将重点介绍这组依赖项:.toml
opentelemetry = { version = "0.17", features = ["rt-tokio", "trace"] } opentelemetry-otlp = { version = "0.10", features = ["metrics", "tokio", "tonic", "tonic-build", "prost", "tls", "tls-roots"], default-features = false} opentelemetry-semantic-conventions = "0.9" tracing = "0.1" tracing-appender = "0.2" tracing-opentelemetry = "0.17" tracing-subscriber = {version = "0.3", features = ["env-filter", "json", "registry"]}
该板条箱使我们能够从较小的行为单元(称为层)编写跟踪订阅者,以收集和扩充跟踪数据。tracing_subscriber
它本身负责在创建时注册新的跨度(具有跨度),记录和附加字段值和后续注释到跨度,以及过滤掉跨度和事件。Subscriber``id
当与订阅者组合时,图层会利用跨度的整个生命周期触发的钩子:
fn on_new_span(&self, _attrs: &Attributes<'_>, _id: &span::Id, _ctx: Context<'_, C>) {...} fn on_record(&self, _span: &Id, _values: &Record<'_>, _ctx: Context<'_, S>) { ... } fn on_follows_from(&self, _span: &Id, _follows: &Id, _ctx: Context<'_, S>) { ... } fn event_enabled(&self, _event: &Event<'_>, _ctx: Context<'_, S>) -> bool { ... } fn on_event(&self, _event: &Event<'_>, _ctx: Context<'_, S>) { ... } fn on_enter(&self, _id: &Id, _ctx: Context<'_, S>) { ... } fn on_exit(&self, _id: &Id, _ctx: Context<'_, S>) { ... } fn on_close(&self, _id: Id, _ctx: Context<'_, S>) { ... }
代码中的层是如何组成的?让我们从设置方法开始,生成一个由四个组合器或层定义的注册表:with
fn setup_tracing( writer: tracing_appender::non_blocking::NonBlocking, settings_otel: &Otel, ) -> Result<()> { let tracer = init_tracer(settings_otel)?; let registry = tracing_subscriber::Registry::default() .with(StorageLayer.with_filter(LevelFilter::TRACE)) .with(tracing_opentelemetry::layer()... .with(LogFmtLayer::new(writer).with_target(true)... .with(MetricsLayer)... ); ...
该函数通常在 中初始化服务器的方法时调用。存储层本身提供零输出,而是充当信息存储,用于收集上下文跟踪信息,以增强和扩展管道中其他层的下游输出。setup_tracingmain()
main.rs
该方法控制为此层启用哪些跨度和事件,我们希望捕获基本上所有内容,这是最详细的选项。with_filter``LevelFilter::TRACE
让我们检查每一层,看看每一层如何对收集的跟踪数据进行操作,并钩接到跨度生命周期。自定义每个层的行为涉及实现与特征关联的生命周期钩子,如下所示。Layer``on_new_span
在此过程中,我们将演示这些行为单元如何增强跨度和事件日志格式,自动派生一些指标,并将我们收集到的内容发送到下游的分布式跟踪平台,如Jaeger,Honeycomb或Datadog。我们将从我们的 开始,它提供了上下文信息,其他层可以从中受益。StorageLayer
存储上下文信息以备将来使用
在新跨度上
impl<S> Layer<S> for StorageLayer where S: Subscriber + for<'span> LookupSpan<'span>, { fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) { let span = ctx.span(id).expect("Span not found"); // We want to inherit the fields from the parent span, if there is one. let mut visitor = if let Some(parent_span) = span.parent() { let mut extensions = parent_span.extensions_mut(); let mut inner = extensions .get_mut::<Storage>() .map(|v| v.to_owned()) .unwrap_or_default(); inner.values.insert( PARENT_SPAN, // "parent_span" Cow::from(parent_span.id().into_u64().to_string()), ); inner } else { Storage::default() }; let mut extensions = span.extensions_mut(); attrs.record(&mut visitor); extensions.insert(visitor); } ...
当启动新的跨度(通过)时,例如,向应用程序请求到终结点,例如,我们的代码会检查我们是否已经在父跨度内。否则,它将默认为新创建的空,这是在引擎盖下包装的内容。on_new_spanPOST
/songsHashmap
Storage::default()
为简单起见,我们默认映射到字符串引用的键和围绕字符串引用的写入时复制 (Cow) 智能指针的值:
#[derive(Clone, Debug, Default)] pub(crate) struct Storage<'a> { values: HashMap<&'a str, Cow<'a, str>>, }
由于跨度,存储跨层在跨度的生命周期内跨层持久化字段,使我们能够将任意数据可变地关联到跨度或从持久数据(包括我们自己的数据结构)中不可变地读取。extensions
更多来自LogRocket的精彩文章:
-
不要错过重播的时刻,这是来自LogRocket的精选时事通讯
-
了解 LogRocket 的 Galileo 如何消除噪音,主动解决应用中的问题
-
使用 React 的 useEffect 来优化应用程序的性能
-
在多个版本的节点之间切换
-
了解如何将 React 儿童道具与 TypeScript 一起使用
-
探索使用 CSS 创建自定义鼠标光标
-
顾问委员会不仅适用于高管。加入LogRocket的内容顾问委员会。您将帮助了解我们创建的内容类型,并获得独家聚会、社交认证和赃物的访问权限。
许多这些生命周期钩子都涉及摔跤,这可能有点冗长。注册表是实际收集和存储跨度数据的东西,然后可以通过实现 .extensions``LookupSpan
另一个要强调的代码是通过访问每种类型的值来记录各种类型的字段值,这是必须实现的特征:attrs.record(&mut visitor)
// Just a sample of the implemented methods impl Visit for Storage<'_> { /// Visit a signed 64-bit integer value. fn record_i64(&mut self, field: &Field, value: i64) { self.values .insert(field.name(), Cow::from(value.to_string())); } ... // elided for brevity fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { // Note: this is invoked via `debug!` and `info! macros let debug_formatted = format!("{:?}", value); self.values.insert(field.name(), Cow::from(debug_formatted)); } ...
一旦我们记录了每种类型的所有值,访问者就会将所有这些值存储在存储中,下游层将来可以使用这些值用于生命周期触发器。Hashmap
记录在案
impl<S> Layer<S> for StorageLayer where S: Subscriber + for<'span> LookupSpan<'span>, { ... // elided for brevity fn on_record(&self, span: &Id, values: &Record<'_>, ctx: Context<'_, S>) { let span = ctx.span(span).expect("Span not found"); let mut extensions = span.extensions_mut(); let visitor = extensions .get_mut::<Storage>() .expect("Visitor not found on 'record'!"); values.record(visitor); } ... // elided for brevity
当我们继续完成每个生命周期触发器时,我们会注意到模式是相似的。我们在跨度的存储扩展中获取一个可变的作用域句柄,并在值到达时记录它们。
此钩子通过类似 或 的调用通知层具有给定标识符的跨度已记录给定值:debug_span!``info_span!
let span = info_span!( "vendor.cdbaby.task", subject = "vendor.cdbaby", category = "vendor" );
事件中
impl<S> Layer<S> for StorageLayer where S: Subscriber + for<'span> LookupSpan<'span>, { ... // elided for brevity fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) { ctx.lookup_current().map(|current_span| { let mut extensions = current_span.extensions_mut(); extensions.get_mut::<Storage>().map(|visitor| { if event .fields() .any(|f| ON_EVENT_KEEP_FIELDS.contains(&f.name())) { event.record(visitor); } }) }); } ... // elided for brevity
对于我们的上下文存储层,通常不需要挂钩到事件(如消息)。但是,这对于存储我们想要保留的事件字段的信息很有价值,这些信息在管道后面的另一个层中可能很有用。tracing::error!
一个例子是存储归因于错误的事件,以便我们可以跟踪指标层中的错误(例如,是与错误键绑定的字段数组)。ON_EVENT_KEEP_FIELDS
进入和关闭时
impl<S> Layer<S> for StorageLayer where S: Subscriber + for<'span> LookupSpan<'span>, { ... // elided for brevity fn on_enter(&self, span: &Id, ctx: Context<'_, S>) { let span = ctx.span(span).expect("Span not found"); let mut extensions = span.extensions_mut(); if extensions.get_mut::<Instant>().is_none() { extensions.insert(Instant::now); } } fn on_close(&self, id: Id, ctx: Context<'_, S>) { let span = ctx.span(&id).expect("Span not found"); let mut extensions = span.extensions_mut(); let elapsed_milliseconds = extensions .get_mut::<Instant>() .map(|i| i.elapsed().as_millis()) .unwrap_or(0); let visitor = extensions .get_mut::<Storage>() .expect("Visitor not found on 'record'"); visitor.values.insert( LATENCY_FIELD, // "latency_ms" Cow::from(format!("{}", elapsed_milliseconds)), ); } ... // elided for brevity
跨度本质上是标记的时间间隔,具有明确的开始和结束。对于跨度的范围,我们希望捕获从输入具有给定跨度 () 到为给定操作关闭之间经过的时间。id``Instant::now
在我们的扩展中存储每个跨度的延迟使其他层能够自动派生指标,并在调试给定跨度的事件日志时有利于探索目的。下面,我们可以看到供应商任务/流程跨度的打开和关闭,从开始到结束需要 18 毫秒:id``id=452612587184455697
level=INFO span_name=vendor.lastfm.task span=452612587184455697 parent_span=span=452612587184455696 span_event=new_span timestamp=2022-10-31T12:35:36.913335Z trace_id=c53cb20e4ab4fa42aa5836d26e974de2 http.client_ip=127.0.0.1:51029 subject=vendor.lastfm application.request_path=/songs http.method=POST category=vendor http.host=127.0.0.1:3030 http.route=/songs request_id=01GGQ0MJ94E24YYZ6FEXFPKVFP level=INFO span_name=vendor.lastfm.task span=452612587184455697 parent_span=span=452612587184455696 span_event=close_span timestamp=2022-10-31T12:35:36.931975Z trace_id=c53cb20e4ab4fa42aa5836d26e974de2 latency_ms=18 http.client_ip=127.0.0.1:51029 subject=vendor.lastfm application.request_path=/songs http.method=POST category=vendor http.host=127.0.0.1:3030 http.route=/songs request_id=01GGQ0MJ94E24YYZ6FEXFPKVFP
使用上下文信息扩充结构化日志
现在,我们将通过查看事件日志格式化层,了解如何利用存储数据进行实际遥测输出:
.with(LogFmtLayer::new(writer).with_target(true)...
在编写自定义层和订阅者实现时,许多示例倾向于自定义格式化程序:
-
一个有用的在线演练,演示如何构建与跟踪箱中默认提供的记录器不同的自定义 JSON 记录器
-
班扬格式
-
Embark Studio's logfmt
注意,上面的日志示例使用相同的格式,灵感来自 InfluxDB 的实现)
我们建议使用已发布的图层或库,或按照上面列出的教程进行操作,以深入了解以首选格式生成数据的细节。
本文构建了我们自己的自定义格式化程序层,对于本文,我们将重新熟悉跨度生命周期,特别是跨度和事件日志,现在将利用我们的存储映射。
在新跨度上
impl<S, Wr, W> Layer<S> for LogFmtLayer<Wr, W> where Wr: Write + 'static, W: for<'writer> MakeWriter<'writer> + 'static, S: Subscriber + for<'span> LookupSpan<'span>, { fn on_new_span(&self, _attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) { let mut p = self.printer.write(); let metadata = ctx.metadata(id).expect("Span missing metadata"); p.write_level(metadata.level()); p.write_span_name(metadata.name()); p.write_span_id(id); p.write_span_event("new_span"); p.write_timestamp(); let span = ctx.span(id).expect("Span not found"); let extensions = span.extensions(); if let Some(visitor) = extensions.get::<Storage>() { for (key, value) in visitor.values() { p.write_kv( decorate_field_name(translate_field_name(key)), value.to_string(), ) } } p.write_newline(); } ... // elided for brevity
上面的代码使用 trait 打印 span 事件的格式化文本表示形式。对打印机的调用和所有打印机方法在后台执行特定的格式属性(在本例中,再次执行)。MakeWriterdecorate_field_name
write``logfmt
回到我们之前的跨度日志示例,现在更明显地设置了像 、 和 这样的键。这里要调用的一段代码是我们如何循环,从存储映射读取的值,提升我们在前一层观察和收集的信息。levelspan
span_name``for (key, value)
我们使用它来提供上下文,以增强另一层中的结构化日志事件。换句话说,我们通过层在跟踪数据上组合特定的子行为,以便为整个跟踪构建单个订阅者。例如,字段键喜欢 和 从此存储层中移除。http.route``http.host
事件中
impl<S, Wr, W> Layer<S> for LogFmtLayer<Wr, W> where Wr: Write + 'static, W: for<'writer> MakeWriter<'writer> + 'static, S: Subscriber + for<'span> LookupSpan<'span>, { ... // elided for brevity fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) { let mut p = self.printer.write(); p.write_level(event.metadata().level()); event.record(&mut *p); //record source information p.write_source_info(event); p.write_timestamp(); ctx.lookup_current().map(|current_span| { p.write_span_id(¤t_span.id()); let extensions = current_span.extensions(); extensions.get::<Storage>().map(|visitor| { for (key, value) in visitor.values() { if !ON_EVENT_SKIP_FIELDS.contains(key) { p.write_kv( decorate_field_name(translate_field_name(key)), value.to_string(), ) } } }) }); p.write_newline(); } ... // elided for brevity
虽然有些乏味,但实现这些跨度生命周期方法的模式变得越来越容易理解。字段键值对(如目标和位置)是根据源信息格式化的,为我们提供了前面看到的。喜欢的键也从上下文存储中取出。target="application::middleware::logging"location="src/middleware/logging.rs:354"
vendor.request_path``vendor.url
虽然正确实现任何格式规范可能需要做更多的工作,但我们现在可以看到跟踪框架提供的精细控制和自定义。这些上下文信息是我们最终能够在请求生命周期内形成相关性的方式。
通过检测和跨度持续时间派生指标
特别是指标,实际上对可观察性本身非常不利,指标的基数,即指标名称和维度值的唯一组合的数量,很容易被滥用。
blank
我们已经展示了如何从事件派生结构化日志。指标本身应由包含它们的事件或跨度形成。
我们仍然需要带外指标,例如围绕进程收集的指标(例如,CPU 使用率、写入/读取的磁盘字节数)。但是,如果我们已经能够在函数级别检测代码以确定何时发生某些事情,那么某些指标难道不能“免费掉出来”吗?如前所述,我们有工具,但我们只需要将其贯穿始终。
跟踪提供了对用户想要检测的函数进行注释的可访问方法,这意味着每次执行注释函数时都要创建、输入和关闭范围。rust 编译器本身在整个代码库中大量使用这些带注释的仪器:
#[instrument(skip(self, op), level = "trace")] pub(super) fn fully_perform_op<R: fmt::Debug, Op>( &mut self, locations: Locations, category: ConstraintCategory<'tcx>, op: Op) -> Fallible<R>
出于我们的目的,让我们看一下一个简单的异步数据库函数,该函数已使用一些非常具体的字段定义进行了检测:save_event
#[instrument( level = "info", name = "record.save_event", skip_all, fields(category="db", subject="aws_db", event_id = %event.event_id, event_type=%event.event_type, otel.kind="client", db.system="aws_db", metric_name="db_event", metric_label_event_table=%self.event_table_name, metric_label_event_type=%event.event_type) err(Display) )] pub async fn save_event(&self, event: &Event) -> anyhow::Result<()> { self.db_client .put_item() .table_name(&self.event_table_name) .set(Some(event)) .send() .await... }
我们的检测函数具有前缀字段,如 、 和 。这些键对应于通常在 Prometheus 监控设置中找到的指标名称和标签。我们稍后会回到这些前缀字段。首先,让我们使用一些额外的过滤器来扩展我们最初设置的过滤器。metricname
event_typeevent_table
MetricsLayer
本质上,这些筛选器执行两件事:1) 为所有跟踪日志级别或更高级别的事件生成指标(即使它们可能不会根据配置的日志级别记录到 stdout);2) 传递附加前缀的检测函数的事件,如上所述。record``name = "record.save_event"
在此之后,为了自动执行指标派生,剩下的就是返回到我们的指标层实现。
关闭时
const PREFIX_LABEL: &str = "metric_label_"; const METRIC_NAME: &str = "metric_name"; const OK: &str = "ok"; const ERROR: &str = "error"; const LABEL: &str = "label"; const RESULT_LABEL: &str = "result"; impl<S> Layer<S> for MetricsLayer where S: Subscriber + for<'span> LookupSpan<'span>, { fn on_close(&self, id: Id, ctx: Context<'_, S>) { let span = ctx.span(&id).expect("Span not found"); let mut extensions = span.extensions_mut(); let elapsed_secs_f64 = extensions .get_mut::<Instant>() .map(|i| i.elapsed().as_secs_f64()) .unwrap_or(0.0); if let Some(visitor) = extensions.get_mut::<Storage>() { let mut labels = vec![]; for (key, value) in visitor.values() { if key.starts_with(PREFIX_LABEL) { labels.push(( key.strip_prefix(PREFIX_LABEL).unwrap_or(LABEL), value.to_string(), )) } } ... // elided for brevity let name = visitor .values() .get(METRIC_NAME) .unwrap_or(&Cow::from(span_name)) .to_string(); if visitor.values().contains_key(ERROR) labels.push((RESULT_LABEL, String::from(ERROR))) } else { labels.push((RESULT_LABEL, String::from(OK))) } ... // elided for brevity metrics::increment_counter!(format!("{}_total", name), &labels); metrics::histogram!( format!("{}_duration_seconds", name), elapsed_secs_f64, &labels ); ... // elided for brevity
在这个例子中有很多位被推来推去,其中一些被省略了。尽管如此,我们总是可以通过 访问跨度间隔的结束,这可以通过宏驱动我们的直方图计算。on_closeelapsed_secs_f64
metrics::histogram!
请注意,我们在此处利用了 metrics-rs 项目。任何人都可以使用另一个提供计数器和直方图支持的指标库以相同的方式对此函数进行建模。从存储映射中,我们提取所有标记的键,并使用这些键为自动派生的递增计数器和直方图生成标签。metric_*
此外,如果我们存储了一个出错的事件,我们可以将其用作标签的一部分,根据 / 区分我们生成的函数。给定任何检测的函数,我们将使用相同的代码行为从中派生指标。ok``error
我们从 Prometheus 端点遇到的输出将显示一个如下所示的计数器:
db_event_total{event_table="events",event_type="Song",result="ok",span_name="save_event\"} 8
检测异步闭包和间接跨度关系
不时出现的一个问题是,如何检测引用间接、非父子关系的跨度的代码,或者所谓的从引用跟随。
这将适用于异步操作,这些操作生成对副作用的下游服务的请求或将数据发送到服务总线的进程,其中直接响应或返回的输出在生成它本身的操作中不起作用。
对于这些情况,我们可以直接检测异步闭包(或期货),方法是在每次轮询和退出未来时进入与我们的异步未来关联的给定跨度(在下面作为参考捕获),如下所示:follows_from``.instrument(process_span)
// Start a span around the context process spawn let process_span = debug_span!( parent: None, "process.async", subject = "songs.async", category = "songs" ); process_span.follows_from(Span::current()); tokio::spawn( async move { match context.process().await { Ok(r) => debug!(song=?r, "successfully processed"), Err(e) => warn!(error=?e, "failed processing"), } } .instrument(process_span), );
分布式跟踪的开放遥测互操作性
可观测性的大部分用处来自于这样一个事实,即当今大多数服务实际上都由许多微服务组成。我们都应该分散思考。
如果各种服务必须跨网络、供应商、云,甚至是面向边缘或本地优先的对等方相互连接,则应强制执行一些标准和与供应商无关的工具。这就是OpenTelemetry(OTel)发挥作用的地方,许多已知的可观测性平台都非常乐意摄取符合OTel标准的遥测数据。
虽然有一整套开源 Rust 工具可以在 OTel 生态系统中工作,但许多著名的 Rust Web 框架还没有以内置的方式采用 OTel 标准的合并。
流行的、包含Web框架,如Actix和Tokio的axum,依赖于自定义实现和外部库来提供集成(分别是actix-web-opentelemetry和axum-tracecing-opentelemetry)。到目前为止,第三方集成一直是最受欢迎的选择,虽然这促进了灵活性和用户控制,但对于那些希望几乎无缝添加集成的人来说,这可能会使它更加困难。
我们不会在这里详细介绍编写自定义实现,但像 Tower 这样的规范 HTTP 中间件允许覆盖请求创建 span 的默认实现。如果按照规范实现,则应在跨度的元数据上设置以下字段:
-
http.client_ip:客户端的 IP 地址
-
http.flavor:使用的协议版本(HTTP/1.1、HTTP/2.0 等)
-
http.host:标头的值Host
-
http.method:请求方法
-
http.route:匹配的路由
-
http.request_content_length:请求内容长度
-
http.response_content_length:响应内容长度
-
http.scheme:使用的 URI 方案(或HTTP``HTTPS)
-
http.status_code:响应状态码
-
http.target:包含路径和查询参数的完整请求目标
-
http.user_agent:标头的值User-Agent
-
otel.kind:通常,在此处查找更多信息server
-
otel.name:由 和 组成的名称http.method``http.route
-
otel.status_code:如果响应成功; 如果是 5xxOK``ERROR
-
trace_id:跟踪的标识符,用于跨进程将特定跟踪的所有跨度组合在一起
初始化跟踪器
通过并公开另一层进行跟踪,我们可以使用该层组成我们的订阅者,以便将OTel上下文信息添加到所有跨度,并将这些跨度连接并发出到Datadog或Honeycomb等可观测性平台,或直接发送到正在运行的Jaeger或Tempo实例,这可以对跟踪数据进行采样以进行可管理消费。tracing-opentelemetry``rust-opentelemetry
初始化 a 以生成和管理跨度非常简单:Tracer
pub fn init_tracer(settings: &Otel) -> Result<Tracer> { global::set_text_map_propagator(TraceContextPropagator::new()); let resource = Resource::new(vec![ otel_semcov::resource::SERVICE_NAME.string(PKG_NAME), otel_semcov::resource::SERVICE_VERSION.string(VERSION), otel_semcov::resource::TELEMETRY_SDK_LANGUAGE.string(LANG), ]); let api_token = MetadataValue::from_str(&settings.api_token)?; let endpoint = &settings.exporter_otlp_endpoint; let mut map = MetadataMap::with_capacity(1); map.insert("x-tracing-service-header", api_token); let trace = opentelemetry_otlp::new_pipeline() .tracing() .with_exporter(exporter(map, endpoint)?) .with_trace_config(sdk::trace::config().with_resource(resource)) .install_batch(runtime::Tokio) .map_err(|e| anyhow!("failed to intialize tracer: {:#?}", e))?; Ok(trace) }
将其包含在我们的层管道中也很简单。我们还可以根据级别进行筛选,并使用动态筛选器跳过我们希望在跟踪中避免的事件:
.with( tracing_opentelemetry::layer() .with_tracer(tracer) .with_filter(LevelFilter::DEBUG) .with_filter(dynamic_filter_fn(|_metadata, ctx| { !ctx.lookup_current() // Exclude the rustls session "Connection" events // which don't have a parent span .map(|s| s.parent().is_none() && s.name() == "Connection") .unwrap_or_default() })), )
通过此管道初始化,我们的所有应用程序跟踪都可以由 Jaeger 等工具引入,如本文前面所示。然后,剩下的就是数据关联、切片和切块。
结论
通过将这些跟踪层组合在一起,我们可以以精细和精细的方式公开系统行为信息,同时获得足够的输出和足够的上下文来开始理解此类行为。所有这些定制仍然是有代价的:它不是完全自动的,但模式是惯用的,并且有许多正常用例可以使用开源层。
在所有事情中,这篇文章应该有助于让用户更轻松地尝试使用跟踪收集自定义应用程序交互,并演示在准备我们的应用程序以处理不可避免的情况方面可以走多远。这只是我们与事件以及它们何时发生的美好友谊的开始,因此,可观察性。从长远来看,我们如何调试和解决问题始终是持续的工作