Observability¶
Cross-cutting utility namespaces: structured logging (log), the named cache
backends (cache), call detail records (cdr), custom Prometheus metrics
(metrics), and periodic / one-shot timers (timer).
log namespace¶
Mock logging namespace — captures log messages for test assertions.
Access captured messages via log.messages::
from siphon import log
log.info("hello")
assert ("info", "hello") in log.messages
messages
instance-attribute
¶
List of (level, message) tuples captured during the test.
cache namespace¶
Named cache backends (Redis + local LRU) from the cache: config list.
Mock cache namespace with an in-memory dict backend.
Pre-populate::
from siphon import cache
cache.set_data("cnam", {"msisdn_display:1234": "Sales"})
Then await cache.fetch("cnam", "msisdn_display:1234") returns "Sales".
fetch
async
¶
Fetch a value from a named cache.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Cache name (from |
required |
key
|
str
|
Cache key string. |
required |
Returns:
| Type | Description |
|---|---|
Optional[str]
|
Cached value or |
store
async
¶
Store a value in a named cache with optional TTL.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Cache name. |
required |
key
|
str
|
Cache key. |
required |
value
|
str
|
Value to store. |
required |
ttl
|
Optional[int]
|
Optional TTL in seconds. Mirrors the real |
None
|
Returns:
| Type | Description |
|---|---|
bool
|
|
list_push
async
¶
Append item to a list under key. Returns the new
length, or None if the cache name is unknown.
The mock stores lists as Python list[str] under the same
keyspace as scalars — fine for tests, but a real script would
not mix scalar and list values on the same key.
list_pop_all
async
¶
Atomically read and clear a list. Returns the items in FIFO order, empty list when the key was absent or the cache is unknown.
list_len
async
¶
Return the length of the list under key (0 for a
missing key), or None if the cache name is unknown.
list_len_sum
async
¶
Sum the lengths of every list whose key starts with
prefix. Returns 0 when nothing matches, None if the
cache name is unknown. Raises ValueError on an empty prefix
(which would scan the entire keyspace).
expire
async
¶
Mock TTL — records the call on self.expirations for
assertions and returns True when the key currently exists
in the cache. The mock does not actually time out entries.
set_data
¶
Create/replace a named cache with test data (test helper).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Cache name. |
required |
data
|
Optional[dict[str, str]]
|
Initial key-value pairs (default: empty dict). |
None
|
cdr namespace¶
Call detail record writing from scripts.
Mock cdr namespace — call detail record writing from scripts.
Usage::
from siphon import cdr
cdr.write(request, extra={"billing_id": "B-12345"}) # proxy handler
cdr.write(call, extra={"billing_id": "B-12345"}) # b2bua handler
cdr.enabled # True if CDR system is active
Test helper::
from siphon_sdk.mock_module import get_cdr
cdrs = get_cdr().records # list of written CDR dicts
With cdr.auto_emit on, the engine does not write a second record: it
merges extra into the record it is already tracking for the call, so the
fields are emitted once at teardown alongside the timings and the disconnect
side. Set auto_emit = True on the mock to test a script against that
shape::
cdr_mock = get_cdr()
cdr_mock.auto_emit = True
... # run the handler
assert cdr_mock.pending[call.id]["billing_id"] == "B-12345"
cdr_mock.finalize(call) # stands in for the BYE
assert cdr_mock.records[0]["billing_id"] == "B-12345"
typed_records
property
¶
The written records as :class:siphon_sdk.cdr.CallDetailRecord — the
same type a collector receives from the HTTP/file sinks.
Assert against this rather than raw dicts when the test cares about the shape the outside world sees::
record = get_cdr().typed_records[-1]
assert record.extra["billing_id"] == "B-12345"
session_key
staticmethod
¶
The key the engine tracks this call's auto-emit record under.
A B2BUA call is keyed on its internal call id (one record for two
dialogs); a proxy call on <Call-ID>\0<From-tag>.
finalize
¶
Emit the record tracked for source — the mock's stand-in for the
BYE that ends the call. Returns the emitted record, or None when
nothing was tracked.
write
¶
Write a CDR for the given request or B2BUA call — or attach the fields to the record siphon is already keeping for it.
With cdr.auto_emit on (auto_emit = True on this mock), extra
is merged into the tracked record instead of queueing a second one, so
the fields are emitted once at teardown on the record that also carries
the timings, duration_secs, response_code and
disconnect_initiator. A standalone record is still written when
there is nothing to merge into.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
'Any'
|
The SIP |
required |
extra
|
'dict[str, str] | None'
|
Optional dict of extra fields to include in the CDR. |
None
|
Returns:
| Type | Description |
|---|---|
bool
|
True if the fields reached a CDR — merged into the tracked record, |
bool
|
or queued as a record of their own. |
Raises:
| Type | Description |
|---|---|
TypeError
|
if |
Example::
from siphon import cdr
@proxy.on_request("INVITE")
def route(request):
cdr.write(request, extra={"billing_id": "B-12345"})
@b2bua.on_answer
def answered(call, reply):
cdr.write(call, extra={"billing_id": "B-12345"})
The record a collector receives¶
This namespace is the writing side. What comes out the other end — the JSON
object every sink writes, its three record kinds, and the typed
siphon_sdk.cdr.CallDetailRecord a collector parses it with — is
CDR records.
Writing to several sinks¶
cdr.backends takes a list, and every record is written to every entry:
cdr:
enabled: true
auto_emit: true
backends:
- type: file
path: /var/log/siphon/cdr.jsonl
rotate_size_mb: 100
- type: http
url: https://collector.example.com/v1/cdr
auth_header: "Bearer ..."
This is the usual shape: the file is the durable copy on the node, and the collector is what the billing or reporting side consumes. With one sink, a record the collector fails to take exists nowhere else, and until the collector exists the file is the only place records can go.
Each sink gets its own channel and its own writer task, so a slow or failing
sink cannot delay, block or drop another's records. channel_size applies per
sink; a full channel drops for that sink alone, logged and counted under its
name in siphon_cdr_dropped_total{sink}. Per-sink ordering is preserved.
There is no retry or durable queue for the HTTP sink. A file sink beside it is how a deployment gets durability without one.
The single form — backend: plus its matching file: / syslog: / http:
block — is unchanged and means one sink of that kind. Setting both backend and
backends is refused at config load rather than guessed at.
metrics namespace¶
Custom Prometheus counters, gauges, and histograms that appear on /metrics.
Mock metrics namespace — custom Prometheus metrics from scripts.
Usage::
from siphon import metrics
counter = metrics.counter("bgcf_calls_total", "Total calls",
labels=["direction"])
counter.labels(direction="outbound").inc()
gauge = metrics.gauge("bgcf_calls_active", "Active calls")
gauge.inc()
hist = metrics.histogram("bgcf_setup_seconds", "Setup time",
buckets=[0.1, 0.5, 1.0])
hist.observe(0.3)
Test helper::
from siphon_sdk.mock_module import get_metrics
m = get_metrics()
c = m.counter("test_total", "Test")
c.inc()
assert c._value == 1.0
counter
¶
Create a new counter metric.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Metric name (e.g. |
required |
help
|
str
|
Description string. |
required |
labels
|
'list[str] | None'
|
Optional list of label names. |
None
|
Returns:
| Type | Description |
|---|---|
MockCounter
|
A MockCounter handle. |
gauge
¶
Create a new gauge metric.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Metric name (e.g. |
required |
help
|
str
|
Description string. |
required |
labels
|
'list[str] | None'
|
Optional list of label names. |
None
|
Returns:
| Type | Description |
|---|---|
MockGauge
|
A MockGauge handle. |
histogram
¶
histogram(
name: str,
help: str,
labels: "list[str] | None" = None,
buckets: "list[float] | None" = None,
) -> MockHistogram
Create a new histogram metric.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Metric name (e.g. |
required |
help
|
str
|
Description string. |
required |
labels
|
'list[str] | None'
|
Optional list of label names. |
None
|
buckets
|
'list[float] | None'
|
Optional list of bucket boundaries. |
None
|
Returns:
| Type | Description |
|---|---|
MockHistogram
|
A MockHistogram handle. |
Counter¶
Mock Prometheus counter.
Usage::
from siphon import metrics
c = metrics.counter("my_total", "My counter")
c.inc()
c.inc(5)
With labels::
c = metrics.counter("my_total", "My counter", labels=["method"])
c.labels(method="INVITE").inc()
Gauge¶
Mock Prometheus gauge.
Usage::
from siphon import metrics
g = metrics.gauge("my_active", "Active things")
g.inc()
g.dec()
g.set(42)
Histogram¶
Mock Prometheus histogram.
Usage::
from siphon import metrics
h = metrics.histogram("my_duration_seconds", "Duration",
buckets=[0.1, 0.5, 1.0])
h.observe(0.3)
timer namespace¶
Periodic (@timer.every) and one-shot (timer.set) callbacks.
Mock timer namespace for periodic timer callbacks.
Timer handlers run on a Tokio interval in the Rust runtime. They receive no SIP request/call context but can use all other namespaces (registrar, cache, gateway, log, etc.).
Example::
from siphon import timer
@timer.every(seconds=30)
async def health_check():
for dest in gateway.list("carriers"):
if not dest.healthy:
log.warn(f"Gateway {dest.uri} is down")
@timer.every(seconds=300, name="stats_push", jitter=10)
def push_stats():
log.info("pushing stats")
scheduled
property
¶
Map of active one-shot timer keys → scheduled delay (ms).
every
¶
Register a periodic timer callback.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
seconds
|
int
|
Interval between invocations. |
required |
name
|
str | None
|
Optional name for logging (defaults to function name). |
None
|
jitter
|
int
|
Random jitter in seconds added to each interval (default 0). |
0
|
Returns:
| Type | Description |
|---|---|
Callable
|
Decorator that registers the function as a timer handler. |
Example::
@timer.every(seconds=60)
def cleanup():
presence.expire_stale()
set
¶
Schedule a one-shot callback under key to fire after delay_ms.
Setting the same key twice cancels the previous timer and reschedules.
In the mock, no tokio runtime fires the callback — tests call
:meth:fire with the key to invoke the handler manually.
cancel
¶
Cancel the one-shot timer registered under key. Returns
True if a timer was cancelled, False if no timer matched.
fire
¶
Test helper: fire the one-shot timer registered under key.
Raises KeyError if no timer matches. Pops the timer so a
subsequent fire for the same key raises.
TimerHandle¶
Returned by timer.set(...) — cancel a scheduled one-shot.
Mock of the TimerHandle returned by timer.set().