Skip to main content

encypher_c2pa/
telemetry.rs

1// Copyright 2026 Encypher Corporation
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::BTreeSet;
5
6use serde::{Deserialize, Serialize};
7
8use crate::{Error, VerificationReport, C2PA_PROFILE};
9
10pub const DEFAULT_TELEMETRY_ENDPOINT: &str =
11    "https://api.encypher.com/api/v1/sdk-validation-failures";
12const TELEMETRY_SCHEMA_VERSION: &str = "1.0";
13const MAX_STATUS_CODES: usize = 8;
14
15/// Per-verification override for persisted failure telemetry consent.
16#[derive(Debug, Clone, Default, Deserialize)]
17#[serde(default, deny_unknown_fields)]
18pub struct TelemetryOptions {
19    /// Override the saved preference for this verification. `None` uses the
20    /// environment or saved per-user setting and prompts on first interactive use.
21    pub enabled: Option<bool>,
22    /// Override the Encypher ingest URL, mainly for self-hosting and tests.
23    pub endpoint: Option<String>,
24    /// Binding name included in the event, such as `rust`, `python`, or `go`.
25    pub sdk_name: Option<String>,
26}
27
28impl TelemetryOptions {
29    pub fn endpoint(&self) -> &str {
30        self.endpoint
31            .as_deref()
32            .unwrap_or(DEFAULT_TELEMETRY_ENDPOINT)
33    }
34}
35
36/// Privacy-bounded validation failure report. It contains no asset bytes,
37/// manifest data, filenames, URLs, keys, trust material, or stable identifiers.
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(deny_unknown_fields)]
40pub struct ValidationFailureTelemetry {
41    pub schema_version: String,
42    pub sdk_name: String,
43    pub sdk_version: String,
44    pub profile: String,
45    pub mime_type: String,
46    pub failure_kind: String,
47    pub status_codes: Vec<String>,
48}
49
50impl ValidationFailureTelemetry {
51    pub fn to_json(&self) -> Result<String, serde_json::Error> {
52        serde_json::to_string(self)
53    }
54}
55
56pub fn validation_failure_telemetry(
57    mime_type: &str,
58    result: &Result<VerificationReport, Error>,
59    options: &TelemetryOptions,
60) -> Option<ValidationFailureTelemetry> {
61    let enabled = crate::telemetry_consent::resolve_telemetry_enabled(options.enabled);
62    validation_failure_telemetry_with_enabled(mime_type, result, options, enabled)
63}
64
65pub(crate) fn validation_failure_telemetry_with_enabled(
66    mime_type: &str,
67    result: &Result<VerificationReport, Error>,
68    options: &TelemetryOptions,
69    enabled: bool,
70) -> Option<ValidationFailureTelemetry> {
71    if !enabled {
72        return None;
73    }
74
75    let (failure_kind, status_codes, canonical_mime) = match result {
76        Ok(report) if report.integrity == "invalid" => {
77            let codes = report
78                .validation_results
79                .failure
80                .iter()
81                .map(|status| status.code.as_str());
82            (
83                "invalid_provenance",
84                bounded_codes(codes),
85                report.mime_type.clone(),
86            )
87        }
88        Err(Error::Verification(_)) => (
89            "verification_error",
90            vec!["verification_error".to_string()],
91            safe_mime(mime_type)?,
92        ),
93        _ => return None,
94    };
95
96    Some(ValidationFailureTelemetry {
97        schema_version: TELEMETRY_SCHEMA_VERSION.to_string(),
98        sdk_name: safe_sdk_name(options.sdk_name.as_deref()),
99        sdk_version: env!("CARGO_PKG_VERSION").to_string(),
100        profile: C2PA_PROFILE.to_string(),
101        mime_type: canonical_mime,
102        failure_kind: failure_kind.to_string(),
103        status_codes,
104    })
105}
106
107fn bounded_codes<'a>(codes: impl Iterator<Item = &'a str>) -> Vec<String> {
108    let codes: Vec<_> = codes
109        .filter(|code| is_safe_token(code))
110        .collect::<BTreeSet<_>>()
111        .into_iter()
112        .take(MAX_STATUS_CODES)
113        .map(str::to_string)
114        .collect();
115    if codes.is_empty() {
116        vec!["invalid_provenance".to_string()]
117    } else {
118        codes
119    }
120}
121
122fn safe_mime(mime_type: &str) -> Option<String> {
123    let mime = crate::c2pa_core::spec::canonicalize_mime(mime_type);
124    crate::c2pa_core::spec::mimes_for_version(crate::c2pa_core::SpecVersion::V2_4)
125        .contains(&mime.as_str())
126        .then_some(mime)
127}
128
129fn safe_sdk_name(value: Option<&str>) -> String {
130    value
131        .filter(|name| !name.is_empty() && name.len() <= 24 && is_safe_token(name))
132        .unwrap_or("rust")
133        .to_string()
134}
135
136fn is_safe_token(value: &str) -> bool {
137    value
138        .bytes()
139        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
140}
141
142#[cfg(feature = "telemetry")]
143pub(crate) fn enqueue(endpoint: &str, event: ValidationFailureTelemetry) {
144    transport::enqueue(endpoint, event);
145}
146
147#[cfg(not(feature = "telemetry"))]
148pub(crate) fn enqueue(_endpoint: &str, _event: ValidationFailureTelemetry) {}
149
150#[cfg(feature = "telemetry")]
151mod transport {
152    use std::sync::mpsc::{sync_channel, SyncSender, TrySendError};
153    use std::sync::LazyLock;
154    use std::time::Duration;
155
156    use super::ValidationFailureTelemetry;
157
158    const QUEUE_CAPACITY: usize = 64;
159    const REQUEST_TIMEOUT: Duration = Duration::from_secs(2);
160    static SENDER: LazyLock<SyncSender<(String, ValidationFailureTelemetry)>> =
161        LazyLock::new(|| {
162            let (sender, receiver) =
163                sync_channel::<(String, ValidationFailureTelemetry)>(QUEUE_CAPACITY);
164            std::thread::Builder::new()
165                .name("encypher-c2pa-telemetry".to_string())
166                .spawn(move || {
167                    let agent = ureq::AgentBuilder::new().timeout(REQUEST_TIMEOUT).build();
168                    while let Ok((endpoint, event)) = receiver.recv() {
169                        let _ = agent.post(&endpoint).send_json(&event);
170                    }
171                })
172                .ok();
173            sender
174        });
175
176    pub(super) fn enqueue(endpoint: &str, event: ValidationFailureTelemetry) {
177        match SENDER.try_send((endpoint.to_string(), event)) {
178            Ok(()) | Err(TrySendError::Full(_)) | Err(TrySendError::Disconnected(_)) => {}
179        }
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::{validation_failure_telemetry, TelemetryOptions};
186    use crate::{
187        FreshnessReport, RevocationReport, TrustReport, ValidationResults, VerificationReport,
188    };
189    use serde_json::Value;
190
191    fn invalid_report() -> VerificationReport {
192        VerificationReport {
193            schema_version: "1.0".to_string(),
194            profile: "c2pa-2.4".to_string(),
195            mime_type: "video/mp4".to_string(),
196            present: true,
197            integrity: "invalid".to_string(),
198            signature: "invalid".to_string(),
199            hard_binding: "mismatch".to_string(),
200            trust: TrustReport {
201                status: "not_evaluated".to_string(),
202                basis: "none".to_string(),
203                validation_time: "2026-08-03T00:00:00Z".to_string(),
204                revocation: RevocationReport {
205                    status: "not_checked".to_string(),
206                    source: "none".to_string(),
207                    responder_signature: "not_applicable".to_string(),
208                },
209                freshness: FreshnessReport {
210                    status: "unknown".to_string(),
211                    as_of: None,
212                },
213            },
214            policy: None,
215            managed_receipt: None,
216            validation_state: "invalid".to_string(),
217            validation_results: ValidationResults {
218                success: vec![],
219                informational: vec![],
220                failure: vec![
221                    crate::VerificationStatus {
222                        code: "claimSignature.mismatch".to_string(),
223                        url: String::new(),
224                        explanation: "must not leave the device".to_string(),
225                        details: None,
226                    },
227                    crate::VerificationStatus {
228                        code: "assertion.dataHash.mismatch".to_string(),
229                        url: String::new(),
230                        explanation: "must not leave the device".to_string(),
231                        details: None,
232                    },
233                ],
234            },
235            manifest_report: Value::Null,
236            content_credentials: None,
237            network: crate::NetworkReport::default(),
238        }
239    }
240
241    #[test]
242    fn emits_only_bounded_codes_for_invalid_provenance() {
243        let options = TelemetryOptions {
244            enabled: Some(true),
245            endpoint: None,
246            sdk_name: Some("python".to_string()),
247        };
248        let event =
249            validation_failure_telemetry("video/mp4", &Ok(invalid_report()), &options).unwrap();
250        assert_eq!(event.sdk_name, "python");
251        assert_eq!(event.mime_type, "video/mp4");
252        assert_eq!(event.failure_kind, "invalid_provenance");
253        assert_eq!(
254            event.status_codes,
255            ["assertion.dataHash.mismatch", "claimSignature.mismatch"]
256        );
257        let json = serde_json::to_string(&event).unwrap();
258        assert!(!json.contains("must not leave"));
259    }
260
261    #[test]
262    fn disabled_telemetry_emits_nothing() {
263        assert!(validation_failure_telemetry(
264            "video/mp4",
265            &Ok(invalid_report()),
266            &TelemetryOptions {
267                enabled: Some(false),
268                ..TelemetryOptions::default()
269            }
270        )
271        .is_none());
272    }
273}