Concepts
Traces
The seventeen phases, what each records, and the difference between missing and unknown.
A trace is the record of one request: which rules were evaluated and what each of them actually saw, the cache key that was computed and the decision it produced, which origin was chosen, and how long every phase took. Every request through the data plane builds one. Most of them build a trace that keeps nothing.
The one-line summary
Trace::one_line() renders a trace as a single line for a terminal. It is eight fields in a fixed order: status, method, host, the request target including its query string, country, the cache decision, the summed stage timings to one decimal place, and — only when some rule both matched and carried an action — the phase and position of that rule.
403 GET example.com /admin RU cache=- 0.1ms rule=custom/4 200 GET example.com /assets/app.css XX cache=HIT 1.2ms
Two placeholders matter. A status of --- means the pipeline returned without setting one, which is a pipeline bug rather than a fact about the request. And cache=- means no cache decision was reached at all: the first line above was blocked before the cache was consulted, which is not the same as cache=BYPASS, where the cache was consulted and declined. The rule suffix names the first evaluation that both matched and carried an action, so a rule whose action is log can appear there without having changed the outcome.
The edge logs one of these per sampled request, at info, on the tracing target guardyn::trace. The default filter is info,guardyn::trace=info, and RUST_LOG replaces it wholesale. GUARDYN_LOG_FORMAT=json switches to JSON output; the plain-text format deliberately omits the target, so selecting trace lines by target only works in JSON.
The phases, in order
Stage declares seventeen stages and derives Ord from declaration order, which is pipeline order. That makes a monotonicity check possible: is_monotonic() is asserted in the tests, so a stage recorded out of order fails a build rather than quietly producing traces that read wrong. Ten of the seventeen are timed today; the rest are named here because a phase you can see is idle beats a phase you cannot see at all.
| Stage | What it covers | Timed |
|---|---|---|
| accept | The TCP accept. Happens before the pipeline is entered. | no |
| tls | The handshake, likewise outside the pipeline. | no |
| resolve_zone | Host to zone, from the in-memory snapshot. An unknown host is a 404 from us that says so, not a 502. | yes |
| normalise | Request-shape checks: Transfer-Encoding together with Content-Length, two Content-Lengths, a missing Host, control characters in the Host or the target. | yes |
| firewall | The ip_access, managed and custom rulesets, in that order, as one figure. | yes |
| rate_limit | Rate limiting. The engine is not built into the node. | no |
| bots | Bot scoring and challenges. Not built. | no |
| redirect | Redirect-phase rules, before any origin work is contemplated. | yes |
| request_transform | Header and path rewrites applied to what the origin will see. | yes |
| function_request | WASM functions on the request side. Not built. | no |
| cache_lookup | The Vary hint, the key, the lookup, and the bypass reason if there is one. | yes |
| origin | Pool selection and the fetch, including retries. | yes |
| function_response | WASM functions on the response side. Not built. | no |
| response_transform | Response-phase rules, then the zone-level headers. | yes |
| compress | Brotli or zstd on the way out. The node applies no compression yet. | no |
| cache_store | The storability decision and the write. | yes |
| log | Recorded as the elapsed time of the whole request, not the cost of logging. | yes |
That last row has a consequence. Because log is recorded as the time since the request began, and total() sums every stage, the millisecond figure in the summary counts the earlier phases twice, and slowest() names log on any request that reached it. The per-stage numbers are sound; the total is not yet a wall-clock measurement. Requests that end early never record log at all, so their totals cover only what ran.
“Did not run” is written down
The convention throughout is that an absent phase says so. When a zone has rules in the rate_limit or bots phase, the pipeline records a degradation saying the phase has rules but its engine is not built into this node yet, so they did not run — and a test asserts exactly that. When a rule asks for a challenge, the request is refused with a 403 and a degradation saying the challenge engine does not exist, rather than being let through, because a bot rule that reads as working is worse than one that visibly fails.
Degradations are deduplicated, since one phase can report the same problem twice, and an empty degradations list is a positive statement: everything the configuration asked for was actually done. The evaluator records a degraded read whether or not the rule matched, because a rule that would have matched had the data been available is exactly the case somebody needs to know about.
What a rule saw
Each evaluation appends a RuleEval: the phase, the ruleset’s label, the rule id, its position, its description, whether it matched, the action if it did, any degradation, and observed — the fields the expression read, paired with the values it read for them. That last one is the point of the whole structure. Not “rule 4 matched” but “rule 4 matched because ip.geoip.country was RU”. A field read twice in one expression is recorded once. The vocabulary itself is in Firewall rules.
| Value | Meaning |
|---|---|
| (absent) | The request genuinely did not carry it: no such header, no such cookie. |
| (could not determine) | The value was unknowable — a GeoIP database that is not loaded, a list that would not resolve. Distinct from absent, on purpose. |
| … (N bytes) | Truncated at 512 bytes, always on a character boundary, with the original length in bytes appended so you know how much was cut. |
Two caps keep a trace smaller than the response it describes. Non-matching evaluations stop being recorded once sixty-four are in the trace, and matches are never dropped at any count, because a managed ruleset with hundreds of rules would otherwise push the one that mattered out of the record.
Country XX
The country in a trace is the two-letter ISO code, or the literal XX when there was no country to record. XX is not a country and does not mean “unknown network”: it means no GeoIP lookup was made. The facts struct keeps that distinction in a separate geo_available flag, so a country rule reports (could not determine) rather than quietly evaluating to false.
The ray id
The ray id is the identifier a customer quotes in a support request, and it is the whole trace id: the UUID in its simple form, all thirty-two hex characters, no hyphens. It is deliberately not the short form that every other id in the system uses for logs and the dashboard.
Sampling, and the ceiling that will surprise you
Sampling is per zone, in basis points, as trace_sample_bp — 10000 is every request, and the default is 100, which is one per cent. The effective rate is the minimum of that and the plan’s trace_sample_max_bp. Zero disables tracing outright; anything at or above 10000 traces unconditionally; in between, the decision is one fastrand draw per request, because this is a sampling decision rather than a security one and a cryptographic draw would show up in a flame chart.
| Plan | trace_sample_max_bp | Effective ceiling | trace_retention_hours |
|---|---|---|---|
| free | 100 | 1% | 6 |
| pro | 2000 | 20% | 168 (7 days) |
| business | 10000 | 100% | 720 (30 days) |
| enterprise | 10000 | 100% | 2160 (90 days) |
Settings are written with a PATCH to the zone, by an admin. The body carries a complete settings object and every field has a default, so a partial body does not leave the rest alone — it resets them. Read the zone, change the one field, send the whole thing back. The control plane defaults to http://127.0.0.1:8787 and authenticates with the guardyn_session cookie that gdn login stores.
# The zone detail response nests the settings under "zone".
curl -s http://127.0.0.1:8787/v1/zones/<zone-id> \
-H 'cookie: guardyn_session=<session>' | jq .zone.settings > settings.json
# Edit trace_sample_bp in settings.json, then send the whole object back.
curl -X PATCH http://127.0.0.1:8787/v1/zones/<zone-id>/settings \
-H 'cookie: guardyn_session=<session>' \
-H 'content-type: application/json' \
-d "{\"settings\": $(cat settings.json)}"Like every other change, it is a draft until you publish it, and gdn plan renders it as trace sampling 1.00% → 100.00% under the zone’s settings.
The two response headers
guardyn-ray carries the ray id, and is added when the zone’s emit_ray_header setting is on, which it is by default. Its neighbour emit_server_header, also on by default, adds Server: guardyn — replacing any header the origin sent rather than appending to it, because a response carrying both tells a visitor more about the stack than either of us intended. Some customers want us invisible; turning both off is how.
guardyn-cache-status is not gated by a setting; it is added to every response that reached a cache decision. Both names are lowercase as sent, and both are in the pipeline hop-by-hop list, which means a client cannot supply them on the way in and an origin cannot claim a cache status on the way out.
| guardyn-cache-status | What happened |
|---|---|
| HIT | Fresh in cache. The origin was not contacted. |
| REVALIDATED | A stored copy was past its TTL and the origin answered 304, so the stored copy was served and its clock reset. |
| STALE-ERROR | The origin failed and a stale copy was served instead of an error page. |
| MISS-STORED | Not in cache, fetched, and stored on the way out. Still a miss to this visitor, but distinguishing it from a plain miss makes fill rate visible. |
| BYPASS | Your configuration said no — caching off, an uncacheable method, an Authorization header, or a cookie named in the bypass list. |
| DYNAMIC | Nothing forbade caching; the response was not cacheable on its own terms. The distinction customers ask about most. |
Why a request was traced
A trace carries its own reason: sampled when the draw selected it, forced for a signed debug header, simulated for one the simulator produced against an origin fetcher that refuses to touch a network, and disabled for a request that was not traced. The debug header is signed because an attacker who could force tracing on every request would multiply our storage cost by a hundred.