Observability¶
Every session and every event carries a request context: an address, a user agent, and what is derived from them, enriched asynchronously so that a sign-in never waits on it. What is recorded, and for how long, is a property of the deployment.
The journal¶
The acts of the provider are written down, kept and read: who did what, when and from where. Nothing
turns the journal on; what a deployment configures is how long an act is kept, journal.retention, 180 days
by default, which is the duration the CNIL deliberation 2021-122 holds for a log of connections. A deployment
under another obligation says its own, and a timed step forgets, once a day, what is past it.
Every event of the core and of the protocols is an act, written under the name the event gives itself:
user.authenticated, authentication.failed, tokens.issued, consent.revoked, session.ended,
tenant.key_rotated and the others. The worker writes it on the lane the events travel on, so the journal is
never on the path of a request, and an act that cannot be written down does not undo what was done.
A row holds what follows and nothing else, no field a caller could fill with a secret:
| What | How it is kept |
|---|---|
| The name of the act | In the clear |
| When it happened | In the clear |
| The tenant | In the clear |
| Where it came from: the address, the user agent and the place derived from them | Sealed under the data key of the tenant, in a family of its own |
| The person it was done in the name of, by the subject their account carries, when it names one | In the clear: a subject names a person without saying anything about them |
| The client it concerned, by the identifier it was registered under, when it concerns one | In the clear |
| The identifier of the row | A UUIDv7, so the journal sorts by time on its primary key |
The reads that hand a person over to a third party are acts too: an introspection that tells a resource
server who a token names, introspection.answered, and a UserInfo answer, userinfo.answered. An
introspection that describes nobody, because the token is inactive or held by a client for itself, writes
nothing.
A person reads the acts of their account on the page of their acts.
An operator reads them with beffroi:user:journal:
bin/console beffroi:user:journal alice --from=2026-01-01 --to=2026-01-31 --act=tokens.issued --format=json
--from and --to are two days in UTC, both included, --act is repeatable, --client narrows to one
application, and --format=json writes the document a request about what happened in somebody's name is
answered with. A period that reaches past what the deployment keeps is answered with how long it keeps an act
and the day that reaches, never with an empty table that looks complete.
Where a request came from¶
| What | Where it comes from |
|---|---|
| The address | The address of the client as Symfony reads it, behind the proxies SYMFONY_TRUSTED_PROXIES names |
| The user agent | The User-Agent header, its surrounding whitespace trimmed and cut at 1024 characters, and none when it is empty |
| The place | Derived from the address on the lane of the events, from the GeoLite2 City and ASN databases, and written back on the session that recorded it |
A console command records beffroi-console/<command> (<user>@<host>) in place of a user agent, and a timed
step beffroi-scheduler/<schedule> (<host>), with no address in either.
A provenance says from where and not only from which address. The databases are read locally: what leaves
the provider is a request for a file once a week, carrying the licence key of the deployment and nothing of
anybody who signed in. They are published by MaxMind under their own licence, so nothing is shipped and
nothing is downloaded without a licence key: a deployment that names none in GEOIP_LICENSE_KEY serves every
flow with a provenance that says an address and no place.
What a client was given¶
At each act that hands something over, the provider keeps what left with it: the scopes, the claims asked for by name (OIDC Core §5.5) and the authorization details (RFC 9396 §7), sealed in a family of their own, and the shape of the access token, under the subject of the person, as the journal names them. What an application obtained of somebody therefore still has an answer after the consent it was given under was taken back.
It is kept exactly as long as the act that gave it, and forgotten by the same timed step: an act whose details are gone, or details whose act is gone, would be half an answer.
What a record used to say¶
The changes of the claims of a person, what each one said before and after, are kept sealed under the data key
of the tenant, under a retention of their own, history.retention, 180 days as well unless the deployment says
otherwise, and forgotten by a timed step once a day. A person reads them at /account/profile/changes.
The logs¶
The logs are for failures. In production, Monolog writes JSON to the standard error of the process, in two handlers:
| Handler | What it writes |
|---|---|
main |
Every channel but deprecations, held in a buffer of the fifty latest records and written, with what follows, once one of them is an error. An error that is a 404 or a 405 is not one |
deprecation |
The deprecations, as they come |
A console command also writes its log lines on its own output, the event and doctrine channels aside. In
the development environment every channel but event goes to var/log/dev.log, from debug up.
Besides what Symfony logs of a request or a message that fails, the provider writes these lines of its own:
| Level | When |
|---|---|
info |
A timed step forgot or removed something: acts of the journal and what a client was given at them, changes of a record, expired archives, rows that could no longer do anything, clients that registered themselves and that nobody used. A GeoLite2 database, or the metadata of a SAML peer, was refreshed or found unchanged. A push service answered that a subscription is gone, and the subscription was taken out |
warning |
A refresh of the GeoLite2 databases was refused. A database of addresses cannot be read, or answered a record the provider cannot read. A push service did not take a notification. A claim was not sent to a SAML service provider, its value being neither a string, an integer, a boolean nor a list of them |
error |
The metadata of a SAML peer were refused, or those of a whole role could not be refreshed. A client that registered itself holds metadata the provider now refuses, and is served as no client |
In production an info or a warning reaches the log only beside an error. The acts are in the journal, not in
the log.
Caddy writes an access log for each of its sites, one line per request with its path and query string.
CADDY_SERVER_LOG_OPTIONS adds directives to both logs. On the site of SERVER_NAME, the authorization
parameter of the query is replaced by REDACTED; the site that asks for a client certificate replaces
nothing. A path is written as it was requested, the link of an enrolment ticket, /enrol/{ticket}/{value},
included.
Metrics and health¶
The provider exports no metrics of its own. The health check of the image reads the metrics endpoint of the
Caddy admin API inside the container, http://localhost:2019/metrics, and the development stack reports its
worker healthy while messenger:consume is the first process of its container.
The panel the provider adds to the Symfony profiler is wired only in a kernel that runs in debug, and the web profiler is registered in the development and test environments alone: a production kernel has neither.