pamoja_ffi/telemetry.rs
1//! The C ABI for device-side telemetry.
2//!
3//! These functions wrap [`pamoja_telemetry`] for callers that reach the SDK
4//! through the flat C boundary: a reporter that ships the events worth their
5//! bytes, counts every event it sees whether it ships or not, and moves its own
6//! bar as the link gets more expensive.
7//!
8//! A reporter holds counters across calls, so it crosses as an opaque handle.
9//! Only the level of an event crosses with it, because the level is the whole of
10//! what the reporter decides on: the code and the optional value belong to the
11//! caller, which keeps them alongside.
12
13use pamoja_telemetry::{Event, Level, LinkCost, Reporter};
14
15/// The number of severity levels, which is the width of a snapshot.
16pub const PAMOJA_TELEMETRY_LEVEL_COUNT: usize = 5;
17
18/// How urgent an event is.
19///
20/// A reporter ships an event whose level is at or above its threshold and drops
21/// anything below it, so the order of these values is what the filter compares.
22#[repr(C)]
23#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
24pub enum PamojaTelemetryLevel {
25 /// Fine-grained detail, useful only when chasing a specific problem.
26 Trace = 0,
27 /// Diagnostic detail for development.
28 Debug = 1,
29 /// A normal, noteworthy event.
30 Info = 2,
31 /// Something unexpected that the node recovered from.
32 Warn = 3,
33 /// A failure that needs attention.
34 Error = 4,
35}
36
37/// What the link back to the network currently costs.
38#[repr(C)]
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub enum PamojaLinkCost {
41 /// Bytes are effectively free, such as on wired power and ethernet.
42 Free = 0,
43 /// Bytes are paid for, such as on a cellular plan.
44 Metered = 1,
45 /// Bytes are scarce, such as on a satellite or long-range radio link.
46 Expensive = 2,
47 /// Nothing can be shipped at all.
48 Offline = 3,
49}
50
51/// A count of everything a reporter has seen, cheap enough to ship anywhere.
52///
53/// This is what a node sends in place of the event stream when the link cannot
54/// carry the detail: the shape of what happened survives even though the
55/// individual events did not.
56#[repr(C)]
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub struct PamojaTelemetrySnapshot {
59 /// How many events were seen at each level, indexed by
60 /// [`PamojaTelemetryLevel`].
61 pub by_level: [u32; PAMOJA_TELEMETRY_LEVEL_COUNT],
62 /// How many events passed the filter and were shipped.
63 pub emitted: u32,
64 /// How many events the filter dropped.
65 pub dropped: u32,
66}
67
68/// An opaque handle to one reporter and its counters.
69///
70/// Create it with [`pamoja_reporter_new`], feed it with
71/// [`pamoja_reporter_record`], and release it with [`pamoja_reporter_free`].
72pub struct PamojaReporter {
73 reporter: Reporter,
74}
75
76/// Returns the level a link cost calls for.
77///
78/// # Arguments
79///
80/// * `cost` - what the link currently costs.
81///
82/// # Returns
83///
84/// The lowest level still worth its bytes at that cost.
85#[no_mangle]
86pub extern "C" fn pamoja_link_cost_threshold(cost: PamojaLinkCost) -> PamojaTelemetryLevel {
87 level(rust_cost(cost).threshold())
88}
89
90/// Creates a reporter that ships events at or above a level.
91///
92/// # Arguments
93///
94/// * `threshold` - the lowest level to ship.
95///
96/// # Returns
97///
98/// A handle the caller must release with [`pamoja_reporter_free`].
99#[no_mangle]
100pub extern "C" fn pamoja_reporter_new(threshold: PamojaTelemetryLevel) -> *mut PamojaReporter {
101 Box::into_raw(Box::new(PamojaReporter {
102 reporter: Reporter::new(rust_level(threshold)),
103 }))
104}
105
106/// Returns the level a reporter is currently shipping from.
107///
108/// # Arguments
109///
110/// * `reporter` - the reporter.
111///
112/// # Returns
113///
114/// The threshold, or [`PamojaTelemetryLevel::Trace`] if `reporter` is null.
115///
116/// # Safety
117///
118/// `reporter` must be a live handle from [`pamoja_reporter_new`], or null.
119#[no_mangle]
120pub unsafe extern "C" fn pamoja_reporter_threshold(
121 reporter: *const PamojaReporter,
122) -> PamojaTelemetryLevel {
123 if reporter.is_null() {
124 return PamojaTelemetryLevel::Trace;
125 }
126 level((*reporter).reporter.threshold())
127}
128
129/// Moves the level a reporter ships from.
130///
131/// # Arguments
132///
133/// * `reporter` - the reporter.
134/// * `threshold` - the new lowest level to ship.
135///
136/// # Safety
137///
138/// `reporter` must be a live handle from [`pamoja_reporter_new`], or null.
139#[no_mangle]
140pub unsafe extern "C" fn pamoja_reporter_set_threshold(
141 reporter: *mut PamojaReporter,
142 threshold: PamojaTelemetryLevel,
143) {
144 if reporter.is_null() {
145 return;
146 }
147 (*reporter).reporter.set_threshold(rust_level(threshold));
148}
149
150/// Moves the threshold to match what the link now costs.
151///
152/// # Arguments
153///
154/// * `reporter` - the reporter.
155/// * `cost` - what the link currently costs.
156///
157/// # Safety
158///
159/// `reporter` must be a live handle from [`pamoja_reporter_new`], or null.
160#[no_mangle]
161pub unsafe extern "C" fn pamoja_reporter_adapt_to(
162 reporter: *mut PamojaReporter,
163 cost: PamojaLinkCost,
164) {
165 if reporter.is_null() {
166 return;
167 }
168 (*reporter).reporter.adapt_to(rust_cost(cost));
169}
170
171/// Records an event and reports whether it is worth shipping.
172///
173/// Only the level crosses the boundary, because the level is the whole of what
174/// the reporter decides on. The code and the optional value stay with the caller,
175/// which is free to ship its own event when this returns `true`.
176///
177/// # Arguments
178///
179/// * `reporter` - the reporter.
180/// * `level` - the severity of the event that occurred.
181///
182/// # Returns
183///
184/// `true` if the event passed the threshold and should be shipped, or `false` if
185/// it was counted and dropped, or `reporter` is null.
186///
187/// # Safety
188///
189/// `reporter` must be a live handle from [`pamoja_reporter_new`], or null.
190#[no_mangle]
191pub unsafe extern "C" fn pamoja_reporter_record(
192 reporter: *mut PamojaReporter,
193 level: PamojaTelemetryLevel,
194) -> bool {
195 if reporter.is_null() {
196 return false;
197 }
198 // The code is a borrowed static string on the Rust side and the reporter never
199 // reads it, so nothing is lost by leaving it empty here.
200 (*reporter)
201 .reporter
202 .record(Event::new(rust_level(level), ""))
203 .is_some()
204}
205
206/// Returns how many events a reporter has seen at a level, shipped or not.
207///
208/// # Arguments
209///
210/// * `reporter` - the reporter.
211/// * `level` - the level to count.
212///
213/// # Returns
214///
215/// The count, or 0 if `reporter` is null.
216///
217/// # Safety
218///
219/// `reporter` must be a live handle from [`pamoja_reporter_new`], or null.
220#[no_mangle]
221pub unsafe extern "C" fn pamoja_reporter_count(
222 reporter: *const PamojaReporter,
223 level: PamojaTelemetryLevel,
224) -> u32 {
225 if reporter.is_null() {
226 return 0;
227 }
228 (*reporter).reporter.count(rust_level(level))
229}
230
231/// Returns how many events a reporter has seen across every level.
232///
233/// # Arguments
234///
235/// * `reporter` - the reporter.
236///
237/// # Returns
238///
239/// The total, or 0 if `reporter` is null.
240///
241/// # Safety
242///
243/// `reporter` must be a live handle from [`pamoja_reporter_new`], or null.
244#[no_mangle]
245pub unsafe extern "C" fn pamoja_reporter_total(reporter: *const PamojaReporter) -> u32 {
246 if reporter.is_null() {
247 return 0;
248 }
249 (*reporter).reporter.total()
250}
251
252/// Returns how many events passed the threshold and were shipped.
253///
254/// # Arguments
255///
256/// * `reporter` - the reporter.
257///
258/// # Returns
259///
260/// The emitted count, or 0 if `reporter` is null.
261///
262/// # Safety
263///
264/// `reporter` must be a live handle from [`pamoja_reporter_new`], or null.
265#[no_mangle]
266pub unsafe extern "C" fn pamoja_reporter_emitted(reporter: *const PamojaReporter) -> u32 {
267 if reporter.is_null() {
268 return 0;
269 }
270 (*reporter).reporter.emitted()
271}
272
273/// Returns how many events the threshold dropped.
274///
275/// # Arguments
276///
277/// * `reporter` - the reporter.
278///
279/// # Returns
280///
281/// The dropped count, or 0 if `reporter` is null.
282///
283/// # Safety
284///
285/// `reporter` must be a live handle from [`pamoja_reporter_new`], or null.
286#[no_mangle]
287pub unsafe extern "C" fn pamoja_reporter_dropped(reporter: *const PamojaReporter) -> u32 {
288 if reporter.is_null() {
289 return 0;
290 }
291 (*reporter).reporter.dropped()
292}
293
294/// Takes a snapshot of the counters to ship in place of the event stream.
295///
296/// # Arguments
297///
298/// * `reporter` - the reporter.
299///
300/// # Returns
301///
302/// The snapshot, or an all-zero snapshot if `reporter` is null.
303///
304/// # Safety
305///
306/// `reporter` must be a live handle from [`pamoja_reporter_new`], or null.
307#[no_mangle]
308pub unsafe extern "C" fn pamoja_reporter_snapshot(
309 reporter: *const PamojaReporter,
310) -> PamojaTelemetrySnapshot {
311 if reporter.is_null() {
312 return PamojaTelemetrySnapshot {
313 by_level: [0; PAMOJA_TELEMETRY_LEVEL_COUNT],
314 emitted: 0,
315 dropped: 0,
316 };
317 }
318 let snapshot = (*reporter).reporter.snapshot();
319 PamojaTelemetrySnapshot {
320 by_level: snapshot.by_level,
321 emitted: snapshot.emitted,
322 dropped: snapshot.dropped,
323 }
324}
325
326/// Releases a reporter handle.
327///
328/// Passing null is a no-op.
329///
330/// # Safety
331///
332/// `reporter` must be a handle from [`pamoja_reporter_new`] that has not already
333/// been freed, or null. After this call it must not be used again.
334#[no_mangle]
335pub unsafe extern "C" fn pamoja_reporter_free(reporter: *mut PamojaReporter) {
336 if !reporter.is_null() {
337 drop(Box::from_raw(reporter));
338 }
339}
340
341/// Maps a Rust level onto the value that crosses the boundary.
342fn level(level: Level) -> PamojaTelemetryLevel {
343 match level {
344 Level::Trace => PamojaTelemetryLevel::Trace,
345 Level::Debug => PamojaTelemetryLevel::Debug,
346 Level::Info => PamojaTelemetryLevel::Info,
347 Level::Warn => PamojaTelemetryLevel::Warn,
348 Level::Error => PamojaTelemetryLevel::Error,
349 }
350}
351
352/// Maps a boundary level back onto the Rust one.
353fn rust_level(level: PamojaTelemetryLevel) -> Level {
354 match level {
355 PamojaTelemetryLevel::Trace => Level::Trace,
356 PamojaTelemetryLevel::Debug => Level::Debug,
357 PamojaTelemetryLevel::Info => Level::Info,
358 PamojaTelemetryLevel::Warn => Level::Warn,
359 PamojaTelemetryLevel::Error => Level::Error,
360 }
361}
362
363/// Maps a boundary link cost back onto the Rust one.
364fn rust_cost(cost: PamojaLinkCost) -> LinkCost {
365 match cost {
366 PamojaLinkCost::Free => LinkCost::Free,
367 PamojaLinkCost::Metered => LinkCost::Metered,
368 PamojaLinkCost::Expensive => LinkCost::Expensive,
369 PamojaLinkCost::Offline => LinkCost::Offline,
370 }
371}
372
373#[cfg(test)]
374mod tests {
375 use std::ptr;
376
377 use super::*;
378
379 #[test]
380 fn a_costly_link_raises_the_bar() {
381 unsafe {
382 let reporter = pamoja_reporter_new(PamojaTelemetryLevel::Trace);
383
384 pamoja_reporter_adapt_to(reporter, PamojaLinkCost::Metered);
385 assert!(!pamoja_reporter_record(
386 reporter,
387 PamojaTelemetryLevel::Debug
388 ));
389 assert!(pamoja_reporter_record(reporter, PamojaTelemetryLevel::Warn));
390
391 assert_eq!(pamoja_reporter_total(reporter), 2);
392 assert_eq!(pamoja_reporter_emitted(reporter), 1);
393 assert_eq!(pamoja_reporter_dropped(reporter), 1);
394
395 pamoja_reporter_free(reporter);
396 }
397 }
398
399 #[test]
400 fn dropped_events_are_still_counted() {
401 unsafe {
402 let reporter = pamoja_reporter_new(PamojaTelemetryLevel::Error);
403
404 for _ in 0..3 {
405 pamoja_reporter_record(reporter, PamojaTelemetryLevel::Info);
406 }
407 pamoja_reporter_record(reporter, PamojaTelemetryLevel::Error);
408
409 let snapshot = pamoja_reporter_snapshot(reporter);
410 assert_eq!(snapshot.by_level[PamojaTelemetryLevel::Info as usize], 3);
411 assert_eq!(snapshot.by_level[PamojaTelemetryLevel::Error as usize], 1);
412 assert_eq!(snapshot.emitted, 1);
413 assert_eq!(snapshot.dropped, 3);
414 assert_eq!(
415 pamoja_reporter_count(reporter, PamojaTelemetryLevel::Info),
416 3
417 );
418
419 pamoja_reporter_free(reporter);
420 }
421 }
422
423 #[test]
424 fn each_link_cost_sets_its_own_bar() {
425 assert_eq!(
426 pamoja_link_cost_threshold(PamojaLinkCost::Free),
427 PamojaTelemetryLevel::Trace
428 );
429 assert_eq!(
430 pamoja_link_cost_threshold(PamojaLinkCost::Metered),
431 PamojaTelemetryLevel::Info
432 );
433 assert_eq!(
434 pamoja_link_cost_threshold(PamojaLinkCost::Expensive),
435 PamojaTelemetryLevel::Warn
436 );
437 assert_eq!(
438 pamoja_link_cost_threshold(PamojaLinkCost::Offline),
439 PamojaTelemetryLevel::Error
440 );
441 }
442
443 #[test]
444 fn a_null_reporter_is_inert() {
445 unsafe {
446 assert!(!pamoja_reporter_record(
447 ptr::null_mut(),
448 PamojaTelemetryLevel::Error
449 ));
450 assert_eq!(pamoja_reporter_total(ptr::null()), 0);
451 assert_eq!(pamoja_reporter_snapshot(ptr::null()).emitted, 0);
452 pamoja_reporter_free(ptr::null_mut());
453 }
454 }
455}