feat(tracing): provider-agnostic exporters with first-class PostHog support (#972)

* feat(tracing): add provider-agnostic exporters with first-class PostHog support

* chore(config): regenerate full reference rendered config for exporters

* refactor(tracing): drop posthog exporter 'enabled' flag per review
This commit is contained in:
Musa 2026-06-25 10:33:46 -07:00 committed by GitHub
parent ff4f2b95d6
commit cdde1adf0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 725 additions and 12 deletions

View file

@ -21,6 +21,10 @@ pub struct AppState {
pub state_storage: Option<Arc<dyn StateStorage>>,
pub llm_provider_url: String,
pub span_attributes: Option<SpanAttributes>,
/// Request header whose value populates the observability `distinct_id`
/// (e.g. PostHog). Sourced from `tracing.exporters[].distinct_id_header`.
/// `None` means LLM events are captured anonymously.
pub distinct_id_header: Option<String>,
/// Shared HTTP client for upstream LLM requests (connection pooling / keep-alive).
pub http_client: reqwest::Client,
pub filter_pipeline: Arc<FilterPipeline>,

View file

@ -93,6 +93,25 @@ async fn llm_chat_inner(
}
});
// Stamp the caller identity for downstream exporters (e.g. PostHog
// `distinct_id`). Sourced from the configured `distinct_id_header`; when the
// header is absent the event is exported anonymously.
if let Some(header_name) = state.distinct_id_header.as_deref() {
if let Some(distinct_id) = request_headers
.get(header_name)
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|s| !s.is_empty())
{
get_active_span(|span| {
span.set_attribute(opentelemetry::KeyValue::new(
tracing_plano::DISTINCT_ID,
distinct_id.to_string(),
));
});
}
}
// Session pinning: extract session ID and check cache before routing
let session_id: Option<String> = request_headers
.get(MODEL_AFFINITY_HEADER)
@ -366,6 +385,19 @@ async fn llm_chat_inner(
};
tracing::Span::current().record(tracing_llm::MODEL_NAME, resolved_model.as_str());
// Record the provider (derived from the `provider/model` prefix) so
// observability exporters can populate provider fields (e.g. PostHog
// `$ai_provider`).
let (resolved_provider, _) = bs_metrics::split_provider_model(&resolved_model);
if resolved_provider != "unknown" {
get_active_span(|span| {
span.set_attribute(opentelemetry::KeyValue::new(
tracing_llm::PROVIDER,
resolved_provider.to_string(),
));
});
}
// --- Phase 4: Forward to upstream and stream back ---
send_upstream(
&state.http_client,

View file

@ -327,6 +327,20 @@ async fn init_app_state(
.as_ref()
.and_then(|tracing| tracing.span_attributes.clone());
// Resolve the distinct_id header from the first PostHog exporter that
// declares one, so the LLM handler can stamp `plano.distinct_id` on spans.
let distinct_id_header = config
.tracing
.as_ref()
.and_then(|tracing| tracing.exporters.as_ref())
.and_then(|exporters| {
exporters.iter().find_map(|exporter| match exporter {
common::configuration::Exporter::Posthog(posthog) => {
posthog.distinct_id_header.clone()
}
})
});
let signals_enabled = !overrides.disable_signals.unwrap_or(false);
Ok(AppState {
@ -338,6 +352,7 @@ async fn init_app_state(
state_storage,
llm_provider_url,
span_attributes,
distinct_id_header,
http_client: reqwest::Client::new(),
filter_pipeline,
signals_enabled,

View file

@ -145,6 +145,11 @@ pub mod plano {
/// "software-engineering"). Absent when the client routed directly
/// to a concrete model.
pub const ROUTE_NAME: &str = "plano.route.name";
/// Caller identity used to populate downstream observability `distinct_id`
/// fields (e.g. PostHog). Sourced from the configured
/// `tracing.exporters[].distinct_id_header`. Absent for anonymous calls.
pub const DISTINCT_ID: &str = "plano.distinct_id";
}
// =============================================================================

View file

@ -11,8 +11,8 @@ use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;
use super::ServiceNameOverrideExporter;
use common::configuration::Tracing;
use super::{PostHogExporter, ServiceNameOverrideExporter};
use common::configuration::{Exporter, PosthogExporter, Tracing};
struct BracketedTime;
@ -90,26 +90,53 @@ pub fn init_tracer(tracing_config: Option<&Tracing>) -> &'static SdkTracerProvid
let random_sampling = tracing_config.and_then(|t| t.random_sampling).unwrap_or(0);
let tracing_enabled = random_sampling > 0 && otel_endpoint.is_some();
// Collect PostHog export destinations from `tracing.exporters`.
let posthog_exporters: Vec<PosthogExporter> = tracing_config
.and_then(|t| t.exporters.as_ref())
.map(|exporters| {
exporters
.iter()
.map(|Exporter::Posthog(posthog)| posthog.clone())
.collect()
})
.unwrap_or_default();
// Tracing is enabled when sampling is on and there is at least one
// destination — an OTLP collector and/or a configured exporter.
let has_destination = otel_endpoint.is_some() || !posthog_exporters.is_empty();
let tracing_enabled = random_sampling > 0 && has_destination;
eprintln!(
"initializing tracing: tracing_enabled={}, otel_endpoint={:?}, random_sampling={}",
tracing_enabled, otel_endpoint, random_sampling
"initializing tracing: tracing_enabled={}, otel_endpoint={:?}, random_sampling={}, posthog_exporters={}",
tracing_enabled, otel_endpoint, random_sampling, posthog_exporters.len()
);
// Create OTLP exporter to send spans to collector.
// Use `if let` to destructure the endpoint, avoiding an unwrap.
if let Some(endpoint) = otel_endpoint.as_deref().filter(|_| tracing_enabled) {
if tracing_enabled {
if std::env::var("OTEL_SERVICE_NAME").is_err() {
std::env::set_var("OTEL_SERVICE_NAME", "plano");
}
// Compose the tracer provider from all configured destinations. Each
// `with_batch_exporter` registers an independent span processor, so
// every span fans out to the OTLP collector and every exporter.
let mut builder = SdkTracerProvider::builder();
// Create ServiceNameOverrideExporter to support per-span service names
// This allows spans to have different service names (e.g., plano(orchestrator),
// plano(filter), plano(llm)) by setting the "service.name.override" attribute
let exporter = ServiceNameOverrideExporter::new(endpoint);
if let Some(endpoint) = otel_endpoint.as_deref() {
builder = builder.with_batch_exporter(ServiceNameOverrideExporter::new(endpoint));
}
let provider = SdkTracerProvider::builder()
.with_batch_exporter(exporter)
.build();
// PostHog exporters translate LLM spans into `$ai_generation` events.
for posthog in &posthog_exporters {
builder = builder.with_batch_exporter(PostHogExporter::new(
&posthog.url,
&posthog.api_key,
posthog.capture_messages.unwrap_or(false),
));
}
let provider = builder.build();
global::set_tracer_provider(provider.clone());

View file

@ -1,6 +1,7 @@
mod constants;
mod custom_attributes;
mod init;
mod posthog_exporter;
mod service_name_exporter;
pub use constants::{
@ -8,6 +9,7 @@ pub use constants::{
};
pub use custom_attributes::collect_custom_trace_attributes;
pub use init::init_tracer;
pub use posthog_exporter::PostHogExporter;
pub use service_name_exporter::{ServiceNameOverrideExporter, SERVICE_NAME_OVERRIDE_KEY};
use opentelemetry::trace::get_active_span;

View file

@ -0,0 +1,402 @@
//! PostHog Span Exporter
//!
//! A custom [`SpanExporter`] that translates Plano's LLM spans into PostHog
//! [`$ai_generation`](https://posthog.com/docs/ai-observability/generations)
//! events and POSTs them to PostHog's capture API (`{url}/batch/`).
//!
//! This makes PostHog a first-class, provider-agnostic export target: a user
//! only points `tracing.exporters` at their PostHog URL + project token and
//! every LLM call is captured — mirroring LiteLLM's `posthog` callback.
//!
//! # Behaviour
//!
//! - Receives every span in the provider (like all batch exporters do) and
//! keeps only LLM generation spans, identified by the presence of the
//! [`llm::MODEL_NAME`] (`llm.model`) attribute.
//! - Maps span attributes onto `$ai_*` PostHog properties (model, provider,
//! latency, tokens, http status, ...).
//! - `distinct_id` is read from the [`plano::DISTINCT_ID`] span attribute (set
//! by the LLM handler from the configured `distinct_id_header`). When absent
//! the event is captured anonymously (`$process_person_profile = false`).
//! - Network failures are logged and dropped — telemetry export never blocks or
//! fails request processing.
use std::time::Duration;
use opentelemetry::{Array, Value};
use opentelemetry_sdk::error::OTelSdkResult;
use opentelemetry_sdk::trace::{SpanData, SpanExporter};
use opentelemetry_sdk::Resource;
use serde_json::{json, Map, Value as JsonValue};
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use super::{http, llm, plano};
/// PostHog event name for an individual LLM call.
const AI_GENERATION_EVENT: &str = "$ai_generation";
/// PostHog capture path appended to the configured host.
const CAPTURE_PATH: &str = "batch/";
/// A [`SpanExporter`] that ships LLM spans to PostHog as `$ai_generation` events.
pub struct PostHogExporter {
client: reqwest::Client,
/// Fully-qualified capture endpoint, e.g. `https://us.i.posthog.com/batch/`.
endpoint: String,
/// PostHog project API key (token).
api_key: String,
/// Whether to attach the truncated user message preview as `$ai_input`.
capture_messages: bool,
}
impl std::fmt::Debug for PostHogExporter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PostHogExporter")
.field("endpoint", &self.endpoint)
.field("capture_messages", &self.capture_messages)
.finish()
}
}
impl PostHogExporter {
/// Create a new PostHog exporter.
///
/// # Arguments
/// * `url` PostHog host (e.g. `https://us.i.posthog.com`). The `/batch/`
/// capture path is appended automatically.
/// * `api_key` PostHog project API key (token).
/// * `capture_messages` when true, send the user message preview as
/// `$ai_input`.
pub fn new(url: &str, api_key: &str, capture_messages: bool) -> Self {
let endpoint = format!("{}/{}", url.trim_end_matches('/'), CAPTURE_PATH);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.unwrap_or_default();
Self {
client,
endpoint,
api_key: api_key.to_string(),
capture_messages,
}
}
/// Build the PostHog `batch` payload from a batch of spans, keeping only LLM
/// generation spans. Returns `None` when no LLM spans are present.
fn build_payload(&self, batch: &[SpanData]) -> Option<JsonValue> {
let events: Vec<JsonValue> = batch
.iter()
.filter_map(|span| self.build_generation_event(span))
.collect();
if events.is_empty() {
return None;
}
Some(json!({
"api_key": self.api_key,
"batch": events,
}))
}
/// Translate a single span into a PostHog `$ai_generation` event, or `None`
/// if the span is not an LLM generation span.
fn build_generation_event(&self, span: &SpanData) -> Option<JsonValue> {
// Only LLM generation spans carry `llm.model`.
let model = find_attr(span, llm::MODEL_NAME)?;
let mut props = Map::new();
props.insert("$ai_model".to_string(), otel_value_to_json(model));
props.insert(
"$ai_trace_id".to_string(),
json!(span.span_context.trace_id().to_string()),
);
if span.parent_span_id != opentelemetry::trace::SpanId::INVALID {
props.insert(
"$ai_parent_id".to_string(),
json!(span.parent_span_id.to_string()),
);
}
if let Some(provider) = find_attr(span, llm::PROVIDER) {
props.insert("$ai_provider".to_string(), otel_value_to_json(provider));
}
// Latency / TTFT are stored in milliseconds; PostHog wants seconds.
if let Some(ms) = find_i64(span, llm::DURATION_MS) {
props.insert("$ai_latency".to_string(), json!(ms as f64 / 1000.0));
}
if let Some(ms) = find_i64(span, llm::TIME_TO_FIRST_TOKEN_MS) {
props.insert(
"$ai_time_to_first_token".to_string(),
json!(ms as f64 / 1000.0),
);
props.insert("$ai_stream".to_string(), json!(true));
}
if let Some(tokens) = find_i64(span, llm::PROMPT_TOKENS) {
props.insert("$ai_input_tokens".to_string(), json!(tokens));
}
if let Some(tokens) = find_i64(span, llm::COMPLETION_TOKENS) {
props.insert("$ai_output_tokens".to_string(), json!(tokens));
}
if let Some(status) = find_i64(span, http::STATUS_CODE) {
props.insert("$ai_http_status".to_string(), json!(status));
if status >= 400 {
props.insert("$ai_is_error".to_string(), json!(true));
}
}
if self.capture_messages {
if let Some(preview) = find_attr(span, llm::USER_MESSAGE_PREVIEW) {
props.insert(
"$ai_input".to_string(),
json!([{ "role": "user", "content": value_to_string(preview) }]),
);
}
}
// distinct_id: identified when the configured header was present,
// otherwise anonymous (do not create/update a person profile).
match find_attr(span, plano::DISTINCT_ID) {
Some(id) => {
props.insert("distinct_id".to_string(), otel_value_to_json(id));
}
None => {
props.insert(
"distinct_id".to_string(),
json!(span.span_context.trace_id().to_string()),
);
props.insert("$process_person_profile".to_string(), json!(false));
}
}
// Pass through any other non-reserved attributes (custom span attributes
// such as static tags or header-derived tenant ids) as plain properties.
for kv in span.attributes.iter() {
let key = kv.key.as_str();
if is_reserved_attr(key) {
continue;
}
props
.entry(key.to_string())
.or_insert_with(|| otel_value_to_json(&kv.value));
}
let mut event = Map::new();
event.insert("event".to_string(), json!(AI_GENERATION_EVENT));
event.insert("properties".to_string(), JsonValue::Object(props));
if let Ok(ts) = OffsetDateTime::from(span.end_time).format(&Rfc3339) {
event.insert("timestamp".to_string(), json!(ts));
}
Some(JsonValue::Object(event))
}
}
impl SpanExporter for PostHogExporter {
fn export(
&self,
batch: Vec<SpanData>,
) -> impl std::future::Future<Output = OTelSdkResult> + Send {
let payload = self.build_payload(&batch);
let client = self.client.clone();
let endpoint = self.endpoint.clone();
async move {
let Some(payload) = payload else {
return Ok(());
};
match client.post(&endpoint).json(&payload).send().await {
Ok(resp) if resp.status().is_success() => {}
Ok(resp) => {
tracing::warn!(
status = %resp.status(),
endpoint = %endpoint,
"PostHog exporter: non-success response"
);
}
Err(e) => {
tracing::warn!(error = ?e, endpoint = %endpoint, "PostHog exporter: request failed");
}
}
Ok(())
}
}
fn shutdown_with_timeout(&mut self, _timeout: Duration) -> OTelSdkResult {
Ok(())
}
fn set_resource(&mut self, _resource: &Resource) {}
}
/// Span attributes that are mapped to dedicated `$ai_*` properties (or are
/// internal plumbing) and should not be duplicated as raw properties.
fn is_reserved_attr(key: &str) -> bool {
matches!(
key,
k if k == llm::MODEL_NAME
|| k == llm::PROVIDER
|| k == llm::DURATION_MS
|| k == llm::TIME_TO_FIRST_TOKEN_MS
|| k == llm::PROMPT_TOKENS
|| k == llm::COMPLETION_TOKENS
|| k == llm::USER_MESSAGE_PREVIEW
|| k == http::STATUS_CODE
|| k == plano::DISTINCT_ID
|| k == super::SERVICE_NAME_OVERRIDE_KEY
)
}
fn find_attr<'a>(span: &'a SpanData, key: &str) -> Option<&'a Value> {
span.attributes
.iter()
.find(|kv| kv.key.as_str() == key)
.map(|kv| &kv.value)
}
fn find_i64(span: &SpanData, key: &str) -> Option<i64> {
match find_attr(span, key)? {
Value::I64(i) => Some(*i),
_ => None,
}
}
fn value_to_string(value: &Value) -> String {
match value {
Value::String(s) => s.as_str().to_string(),
other => other.to_string(),
}
}
fn otel_value_to_json(value: &Value) -> JsonValue {
match value {
Value::Bool(b) => json!(b),
Value::I64(i) => json!(i),
Value::F64(f) => json!(f),
Value::String(s) => json!(s.as_str()),
Value::Array(arr) => match arr {
Array::Bool(v) => json!(v),
Array::I64(v) => json!(v),
Array::F64(v) => json!(v),
Array::String(v) => json!(v.iter().map(|s| s.as_str()).collect::<Vec<_>>()),
_ => JsonValue::Null,
},
_ => json!(value.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use opentelemetry::trace::{
SpanContext, SpanId, SpanKind, Status, TraceFlags, TraceId, TraceState,
};
use opentelemetry::KeyValue;
use opentelemetry_sdk::trace::{SpanData, SpanEvents, SpanLinks};
use std::borrow::Cow;
use std::time::SystemTime;
fn span_with_attrs(attrs: Vec<KeyValue>) -> SpanData {
SpanData {
span_context: SpanContext::new(
TraceId::from_bytes([
0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, 0x9a,
0xbc, 0xde, 0xf0,
]),
SpanId::from_bytes([0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]),
TraceFlags::SAMPLED,
false,
TraceState::default(),
),
parent_span_id: SpanId::INVALID,
parent_span_is_remote: false,
span_kind: SpanKind::Client,
name: Cow::Borrowed("llm"),
start_time: SystemTime::UNIX_EPOCH,
end_time: SystemTime::UNIX_EPOCH,
attributes: attrs,
dropped_attributes_count: 0,
events: SpanEvents::default(),
links: SpanLinks::default(),
status: Status::Unset,
instrumentation_scope: Default::default(),
}
}
fn props(event: &JsonValue) -> &Map<String, JsonValue> {
event["properties"].as_object().unwrap()
}
#[test]
fn non_llm_span_is_skipped() {
let exporter = PostHogExporter::new("https://us.i.posthog.com", "phc_x", false);
let span = span_with_attrs(vec![KeyValue::new("routing.strategy", "least-latency")]);
assert!(exporter.build_generation_event(&span).is_none());
}
#[test]
fn maps_llm_attributes_to_ai_properties() {
let exporter = PostHogExporter::new("https://us.i.posthog.com/", "phc_x", false);
let span = span_with_attrs(vec![
KeyValue::new(llm::MODEL_NAME, "gpt-5-mini"),
KeyValue::new(llm::PROVIDER, "openai"),
KeyValue::new(llm::DURATION_MS, 1500_i64),
KeyValue::new(llm::TIME_TO_FIRST_TOKEN_MS, 250_i64),
KeyValue::new(llm::PROMPT_TOKENS, 10_i64),
KeyValue::new(llm::COMPLETION_TOKENS, 20_i64),
KeyValue::new(http::STATUS_CODE, 200_i64),
KeyValue::new("tenant.id", "acme"),
]);
let event = exporter.build_generation_event(&span).unwrap();
assert_eq!(event["event"], json!("$ai_generation"));
let p = props(&event);
assert_eq!(p["$ai_model"], json!("gpt-5-mini"));
assert_eq!(p["$ai_provider"], json!("openai"));
assert_eq!(p["$ai_latency"], json!(1.5));
assert_eq!(p["$ai_time_to_first_token"], json!(0.25));
assert_eq!(p["$ai_stream"], json!(true));
assert_eq!(p["$ai_input_tokens"], json!(10));
assert_eq!(p["$ai_output_tokens"], json!(20));
assert_eq!(p["$ai_http_status"], json!(200));
// Anonymous (no distinct id header captured).
assert_eq!(p["$process_person_profile"], json!(false));
// Custom passthrough attribute preserved.
assert_eq!(p["tenant.id"], json!("acme"));
// No $ai_input unless capture_messages is enabled.
assert!(!p.contains_key("$ai_input"));
}
#[test]
fn uses_distinct_id_and_flags_errors() {
let exporter = PostHogExporter::new("https://us.i.posthog.com", "phc_x", true);
let span = span_with_attrs(vec![
KeyValue::new(llm::MODEL_NAME, "gpt-5-mini"),
KeyValue::new(plano::DISTINCT_ID, "user_123"),
KeyValue::new(llm::USER_MESSAGE_PREVIEW, "hello"),
KeyValue::new(http::STATUS_CODE, 500_i64),
]);
let event = exporter.build_generation_event(&span).unwrap();
let p = props(&event);
assert_eq!(p["distinct_id"], json!("user_123"));
assert!(!p.contains_key("$process_person_profile"));
assert_eq!(p["$ai_is_error"], json!(true));
assert_eq!(
p["$ai_input"],
json!([{ "role": "user", "content": "hello" }])
);
}
#[test]
fn payload_wraps_events_with_api_key() {
let exporter = PostHogExporter::new("https://us.i.posthog.com", "phc_secret", false);
let span = span_with_attrs(vec![KeyValue::new(llm::MODEL_NAME, "gpt-5-mini")]);
let payload = exporter.build_payload(&[span]).unwrap();
assert_eq!(payload["api_key"], json!("phc_secret"));
assert_eq!(payload["batch"].as_array().unwrap().len(), 1);
}
}

View file

@ -251,6 +251,11 @@ pub struct Tracing {
pub random_sampling: Option<u32>,
pub opentracing_grpc_endpoint: Option<String>,
pub span_attributes: Option<SpanAttributes>,
/// Provider-agnostic telemetry export destinations. Each entry is tagged by
/// its `type` (e.g. `posthog`) so new backends can be added without breaking
/// existing configs. LLM spans are translated into each backend's native
/// event format and streamed in addition to any `opentracing_grpc_endpoint`.
pub exporters: Option<Vec<Exporter>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
@ -260,6 +265,36 @@ pub struct SpanAttributes {
pub static_attributes: Option<HashMap<String, String>>,
}
/// A telemetry export destination configured under `tracing.exporters`.
///
/// The list is provider-agnostic; each variant is internally tagged by its
/// `type` field (e.g. `type: posthog`). Additional backends (datadog, raw
/// otlp, ...) can be added as new variants without breaking existing configs.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Exporter {
/// PostHog AI observability. LLM spans are converted into PostHog
/// `$ai_generation` events and POSTed to the configured `url`.
Posthog(PosthogExporter),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PosthogExporter {
/// PostHog host, e.g. `https://us.i.posthog.com`. The `/batch/` capture
/// path is appended automatically.
pub url: String,
/// PostHog project API key (token). Supports `$ENV_VAR` expansion at render
/// time, e.g. `$POSTHOG_API_KEY`.
pub api_key: String,
/// Optional request header whose value is used as the PostHog `distinct_id`.
/// When unset (or the header is missing on a request) events are captured
/// anonymously.
pub distinct_id_header: Option<String>,
/// When true, include the truncated user message preview as `$ai_input`.
/// Defaults to `false` to avoid sending prompt content off-box.
pub capture_messages: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
pub enum GatewayMode {
#[serde(rename = "llm")]
@ -865,4 +900,47 @@ disable_signals: false
let overrides: super::Overrides = serde_yaml::from_str(yaml_missing).unwrap();
assert_eq!(overrides.disable_signals, None);
}
#[test]
fn test_tracing_posthog_exporter_deserialize() {
let yaml = r#"
random_sampling: 100
exporters:
- type: posthog
url: https://us.i.posthog.com
api_key: phc_secret
distinct_id_header: x-user-id
capture_messages: true
"#;
let tracing: super::Tracing = serde_yaml::from_str(yaml).unwrap();
let exporters = tracing.exporters.expect("exporters should be parsed");
assert_eq!(exporters.len(), 1);
match &exporters[0] {
super::Exporter::Posthog(posthog) => {
assert_eq!(posthog.url, "https://us.i.posthog.com");
assert_eq!(posthog.api_key, "phc_secret");
assert_eq!(posthog.distinct_id_header.as_deref(), Some("x-user-id"));
assert_eq!(posthog.capture_messages, Some(true));
}
}
}
#[test]
fn test_tracing_posthog_exporter_minimal() {
let yaml = r#"
exporters:
- type: posthog
url: https://eu.i.posthog.com
api_key: phc_eu
"#;
let tracing: super::Tracing = serde_yaml::from_str(yaml).unwrap();
let exporters = tracing.exporters.unwrap();
match &exporters[0] {
super::Exporter::Posthog(posthog) => {
assert_eq!(posthog.url, "https://eu.i.posthog.com");
assert_eq!(posthog.distinct_id_header, None);
assert_eq!(posthog.capture_messages, None);
}
}
}
}