наблюдаемость: завершить базовый контур Community
Добавить структурированные журналы, метрики, трассировку и безопасный канал критических ошибок. Усилить границы рантайма, тесты, проверку зависимостей и сценарии развёртывания.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "crank-trace"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tracing-subscriber.workspace = true
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Закрытый семантический контракт spans Crank.
|
||||
//!
|
||||
//! Этот crate не настраивает subscriber и не знает об OTLP. Он ограничивает
|
||||
//! имена и атрибуты стадий статическим словарём, чтобы продуктовые crate не
|
||||
//! могли случайно экспортировать пользовательские данные.
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
use tracing::{Instrument, Span, field::Empty, info_span};
|
||||
|
||||
macro_rules! stage_span {
|
||||
($name:literal) => {
|
||||
info_span!(
|
||||
target: "crank::trace",
|
||||
$name,
|
||||
outcome = Empty,
|
||||
error.category = Empty,
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Stage {
|
||||
McpRateLimit,
|
||||
McpAccessCheck,
|
||||
McpCatalogLoad,
|
||||
McpToolsResolve,
|
||||
ApprovalCheck,
|
||||
RuntimeExecute,
|
||||
RuntimeArgumentsMap,
|
||||
RuntimeIdempotency,
|
||||
UpstreamHttp,
|
||||
RuntimeResponseTransform,
|
||||
AuthResolve,
|
||||
ApprovalRecovery,
|
||||
HistoryWrite,
|
||||
DbQuery,
|
||||
}
|
||||
|
||||
impl Stage {
|
||||
pub fn span(self) -> Span {
|
||||
match self {
|
||||
Self::McpRateLimit => stage_span!("mcp.rate_limit"),
|
||||
Self::McpAccessCheck => stage_span!("mcp.access.check"),
|
||||
Self::McpCatalogLoad => stage_span!("mcp.catalog.load"),
|
||||
Self::McpToolsResolve => stage_span!("mcp.tools.resolve"),
|
||||
Self::ApprovalCheck => stage_span!("approval.check"),
|
||||
Self::RuntimeExecute => stage_span!("runtime.execute"),
|
||||
Self::RuntimeArgumentsMap => stage_span!("runtime.arguments.map"),
|
||||
Self::RuntimeIdempotency => stage_span!("runtime.idempotency"),
|
||||
Self::UpstreamHttp => stage_span!("upstream.http"),
|
||||
Self::RuntimeResponseTransform => stage_span!("runtime.response.transform"),
|
||||
Self::AuthResolve => stage_span!("auth.resolve"),
|
||||
Self::ApprovalRecovery => stage_span!("approval.recovery"),
|
||||
Self::HistoryWrite => stage_span!("history.write"),
|
||||
Self::DbQuery => info_span!(
|
||||
target: "crank::trace",
|
||||
"db.query",
|
||||
outcome = Empty,
|
||||
error.category = Empty,
|
||||
db.system = "postgresql",
|
||||
db.operation = Empty,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn db_span(self, operation: DbOperation) -> Option<Span> {
|
||||
if self != Self::DbQuery {
|
||||
return None;
|
||||
}
|
||||
let span = self.span();
|
||||
span.record("db.operation", operation.as_str());
|
||||
Some(span)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn observe_db_query<T, E>(
|
||||
operation: DbOperation,
|
||||
future: impl Future<Output = Result<T, E>>,
|
||||
) -> Result<T, E> {
|
||||
let span = Stage::DbQuery
|
||||
.db_span(operation)
|
||||
.expect("database operation requires db.query stage");
|
||||
let result = future.instrument(span.clone()).await;
|
||||
match &result {
|
||||
Ok(_) => StageOutcome::Success.record(&span),
|
||||
Err(_) => {
|
||||
StageOutcome::Error.record(&span);
|
||||
ErrorCategory::Database.record(&span);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum StageOutcome {
|
||||
Success,
|
||||
Error,
|
||||
Allowed,
|
||||
Denied,
|
||||
Required,
|
||||
Replay,
|
||||
Execute,
|
||||
Skipped,
|
||||
CacheHit,
|
||||
}
|
||||
|
||||
impl StageOutcome {
|
||||
pub fn record(self, span: &Span) {
|
||||
span.record("outcome", self.as_str());
|
||||
}
|
||||
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Success => "success",
|
||||
Self::Error => "error",
|
||||
Self::Allowed => "allowed",
|
||||
Self::Denied => "denied",
|
||||
Self::Required => "required",
|
||||
Self::Replay => "replay",
|
||||
Self::Execute => "execute",
|
||||
Self::Skipped => "skipped",
|
||||
Self::CacheHit => "cache_hit",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ErrorCategory {
|
||||
Access,
|
||||
RateLimit,
|
||||
Catalog,
|
||||
Approval,
|
||||
Idempotency,
|
||||
Schema,
|
||||
Mapping,
|
||||
Upstream,
|
||||
Transformation,
|
||||
History,
|
||||
Database,
|
||||
Concurrency,
|
||||
Configuration,
|
||||
Internal,
|
||||
}
|
||||
|
||||
impl ErrorCategory {
|
||||
pub fn record(self, span: &Span) {
|
||||
span.record("error.category", self.as_str());
|
||||
}
|
||||
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Access => "access",
|
||||
Self::RateLimit => "rate_limit",
|
||||
Self::Catalog => "catalog",
|
||||
Self::Approval => "approval",
|
||||
Self::Idempotency => "idempotency",
|
||||
Self::Schema => "schema",
|
||||
Self::Mapping => "mapping",
|
||||
Self::Upstream => "upstream",
|
||||
Self::Transformation => "transformation",
|
||||
Self::History => "history",
|
||||
Self::Database => "database",
|
||||
Self::Concurrency => "concurrency",
|
||||
Self::Configuration => "configuration",
|
||||
Self::Internal => "internal",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum DbOperation {
|
||||
MachineAccessRead,
|
||||
MachineAccessTouch,
|
||||
CatalogLoad,
|
||||
ApprovalRead,
|
||||
ApprovalWrite,
|
||||
AuthProfileRead,
|
||||
SecretRead,
|
||||
SecretTouch,
|
||||
InvocationHistoryWrite,
|
||||
}
|
||||
|
||||
impl DbOperation {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::MachineAccessRead => "machine_access.read",
|
||||
Self::MachineAccessTouch => "machine_access.touch",
|
||||
Self::CatalogLoad => "catalog.load",
|
||||
Self::ApprovalRead => "approval.read",
|
||||
Self::ApprovalWrite => "approval.write",
|
||||
Self::AuthProfileRead => "auth_profile.read",
|
||||
Self::SecretRead => "secret.read",
|
||||
Self::SecretTouch => "secret.touch",
|
||||
Self::InvocationHistoryWrite => "invocation_history.write",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crank_trace::{DbOperation, ErrorCategory, Stage, StageOutcome};
|
||||
use tracing::{Id, Subscriber, field::Visit};
|
||||
use tracing_subscriber::{Layer, layer::SubscriberExt, registry::LookupSpan};
|
||||
|
||||
#[test]
|
||||
fn stage_names_and_attributes_are_closed() {
|
||||
let captured = Arc::new(Mutex::new(Vec::new()));
|
||||
let subscriber = tracing_subscriber::registry().with(CaptureLayer(Arc::clone(&captured)));
|
||||
|
||||
tracing::subscriber::with_default(subscriber, || {
|
||||
let span = Stage::RuntimeExecute.span();
|
||||
StageOutcome::Success.record(&span);
|
||||
ErrorCategory::Mapping.record(&span);
|
||||
drop(span);
|
||||
|
||||
let db_span = Stage::DbQuery
|
||||
.db_span(DbOperation::InvocationHistoryWrite)
|
||||
.expect("db stage accepts a db operation");
|
||||
StageOutcome::Error.record(&db_span);
|
||||
drop(db_span);
|
||||
});
|
||||
|
||||
let spans = captured.lock().expect("captured spans");
|
||||
assert_eq!(spans[0].name, "runtime.execute");
|
||||
assert_eq!(spans[0].fields["outcome"], "success");
|
||||
assert_eq!(spans[0].fields["error.category"], "mapping");
|
||||
assert_eq!(spans[1].name, "db.query");
|
||||
assert_eq!(spans[1].fields["db.system"], "postgresql");
|
||||
assert_eq!(spans[1].fields["db.operation"], "invocation_history.write");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_database_stage_rejects_database_attributes() {
|
||||
assert!(
|
||||
Stage::RuntimeExecute
|
||||
.db_span(DbOperation::CatalogLoad)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CaptureLayer(Arc<Mutex<Vec<CapturedSpan>>>);
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CapturedSpan {
|
||||
name: &'static str,
|
||||
fields: std::collections::BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for CaptureLayer
|
||||
where
|
||||
S: Subscriber + for<'lookup> LookupSpan<'lookup>,
|
||||
{
|
||||
fn on_new_span(
|
||||
&self,
|
||||
attributes: &tracing::span::Attributes<'_>,
|
||||
id: &Id,
|
||||
context: tracing_subscriber::layer::Context<'_, S>,
|
||||
) {
|
||||
let mut visitor = FieldVisitor::default();
|
||||
attributes.record(&mut visitor);
|
||||
context
|
||||
.span(id)
|
||||
.expect("span exists")
|
||||
.extensions_mut()
|
||||
.insert(self.0.lock().expect("capture lock").len());
|
||||
self.0.lock().expect("capture lock").push(CapturedSpan {
|
||||
name: attributes.metadata().name(),
|
||||
fields: visitor.fields,
|
||||
});
|
||||
}
|
||||
|
||||
fn on_record(
|
||||
&self,
|
||||
id: &Id,
|
||||
values: &tracing::span::Record<'_>,
|
||||
context: tracing_subscriber::layer::Context<'_, S>,
|
||||
) {
|
||||
let span = context.span(id).expect("span exists");
|
||||
let index = *span.extensions().get::<usize>().expect("capture index");
|
||||
let mut visitor = FieldVisitor::default();
|
||||
values.record(&mut visitor);
|
||||
self.0.lock().expect("capture lock")[index]
|
||||
.fields
|
||||
.extend(visitor.fields);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FieldVisitor {
|
||||
fields: std::collections::BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Visit for FieldVisitor {
|
||||
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
|
||||
self.fields
|
||||
.insert(field.name().to_owned(), value.to_owned());
|
||||
}
|
||||
|
||||
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
|
||||
self.fields
|
||||
.insert(field.name().to_owned(), format!("{value:?}"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user