Skip to main content

systemprompt_models/profile/
observability.rs

1//! Where this instance ships its own telemetry.
2//!
3//! The gateway already *ingests* OTLP; this block is the other direction: the
4//! `otlp_export` job tails the audit tables and posts every completed AI
5//! request as a span (tool calls and governance decisions as its children) and
6//! every `logs` row as a log record to the configured collector. Metrics are
7//! not exported here — they stay on the Prometheus `/metrics` listener.
8//!
9//! `endpoint` is the collector base URL (OTLP/HTTP appends `/v1/traces` and
10//! `/v1/logs`); `headers` go verbatim on every export request; `signals`
11//! selects what is shipped; `batch_seconds` is the minimum spacing between
12//! two exports of one signal — the job's cron tick is the upper bound on how
13//! often it runs, this the lower bound on how often it ships.
14//!
15//! Copyright (c) systemprompt.io — Business Source License 1.1.
16//! See <https://systemprompt.io> for licensing details.
17
18use std::collections::BTreeMap;
19
20use serde::{Deserialize, Serialize};
21
22#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
23#[serde(deny_unknown_fields)]
24pub struct ObservabilityConfig {
25    #[serde(default)]
26    pub otlp: Option<OtlpExportConfig>,
27}
28
29impl ObservabilityConfig {
30    #[must_use]
31    pub const fn otlp(&self) -> Option<&OtlpExportConfig> {
32        self.otlp.as_ref()
33    }
34}
35
36/// An OTLP collector to export traces and logs to.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
38#[serde(deny_unknown_fields)]
39pub struct OtlpExportConfig {
40    pub endpoint: String,
41
42    #[serde(default)]
43    pub protocol: OtlpProtocol,
44
45    #[serde(default)]
46    pub headers: BTreeMap<String, String>,
47
48    #[serde(default = "default_signals")]
49    pub signals: Vec<OtlpSignal>,
50
51    #[serde(default = "default_batch_seconds")]
52    pub batch_seconds: u64,
53}
54
55impl OtlpExportConfig {
56    pub const DEFAULT_BATCH_SECONDS: u64 = 15;
57    pub const MIN_BATCH_SECONDS: u64 = 1;
58    pub const MAX_BATCH_SECONDS: u64 = 3600;
59
60    #[must_use]
61    pub fn exports(&self, signal: OtlpSignal) -> bool {
62        self.signals.contains(&signal)
63    }
64
65    // Why: OTLP/HTTP (opentelemetry-proto, "OTLP/HTTP request") fixes the
66    // per-signal path: `/v1/traces`, `/v1/logs`. An endpoint that already
67    // ends in that path is used as given.
68    #[must_use]
69    pub fn signal_url(&self, signal: OtlpSignal) -> String {
70        let base = self.endpoint.trim_end_matches('/');
71        let path = signal.http_path();
72        if base.ends_with(path) {
73            base.to_owned()
74        } else {
75            format!("{base}{path}")
76        }
77    }
78}
79
80#[derive(
81    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
82)]
83#[serde(rename_all = "lowercase")]
84pub enum OtlpProtocol {
85    #[default]
86    Http,
87    Grpc,
88}
89
90impl OtlpProtocol {
91    #[must_use]
92    pub const fn label(self) -> &'static str {
93        match self {
94            Self::Http => "http",
95            Self::Grpc => "grpc",
96        }
97    }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
101#[serde(rename_all = "lowercase")]
102pub enum OtlpSignal {
103    Traces,
104    Logs,
105}
106
107impl OtlpSignal {
108    pub const ALL: [Self; 2] = [Self::Traces, Self::Logs];
109
110    #[must_use]
111    pub const fn label(self) -> &'static str {
112        match self {
113            Self::Traces => "traces",
114            Self::Logs => "logs",
115        }
116    }
117
118    #[must_use]
119    pub const fn http_path(self) -> &'static str {
120        match self {
121            Self::Traces => "/v1/traces",
122            Self::Logs => "/v1/logs",
123        }
124    }
125
126    #[must_use]
127    pub fn parse(label: &str) -> Option<Self> {
128        Self::ALL.into_iter().find(|s| s.label() == label)
129    }
130}
131
132impl std::fmt::Display for OtlpSignal {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        f.write_str(self.label())
135    }
136}
137
138fn default_signals() -> Vec<OtlpSignal> {
139    OtlpSignal::ALL.to_vec()
140}
141
142const fn default_batch_seconds() -> u64 {
143    OtlpExportConfig::DEFAULT_BATCH_SECONDS
144}