Skip to content
Development docs · last release v1.8.1 · tested on Kubernetes 1.35 to 1.37Release notes

Logging

kube-oidc-proxy writes one structured stream. Every first-party record is a single JSON object on stdout — startup, readiness, per-request access decisions, cache lookups, shutdown — encoded by log/slog and carrying the same envelope. There is no second text log to collect and no separate access-log file: --logging-format picks the encoding for the whole stream and -v picks how much of it you see.

Every record the proxy emits itself carries the same six keys before any event-specific field:

FieldTypeValues / formatNotes
timestringRFC 3339 with sub-second precisionslog default
levelstringERROR, WARN, INFO, DEBUGslog default
msgstringstatic text per event, never interpolatedquery on event_type, not msg
schema_versionint1bumped only on a breaking field change
componentstringstartup, server, oidc, readiness, request, tokenreview, sar, audit, upstream, shutdown, metrics, k8swhich subsystem spoke; k8s marks bridged library output
event_typestring<domain>.<object>.<action>, one of the 43 registered valuesabsent on component=k8s records

An access decision looks like this (line-wrapped here, one line in the stream):

{"time":"2026-09-04T10:53:24.615018Z","level":"INFO","msg":"access decision","schema_version":1,
"component":"request","event_type":"request.access.decided",
"request_id":"7f1a9c1e-6a2b-4a1f-9f0e-3d9d1c2b5a04","event":"AuSuccess","src_ip":"10.42.1.3",
"path":"/api/v1/namespaces/default/pods","forwarded_for_untrusted":"10.42.0.5","http_method":"GET",
"auth_method":"oidc","issuer_name":"corp","k8s_verb":"list","k8s_api_group":"","k8s_resource":"pods",
"k8s_namespace":"default","decision":"allow","inbound_user":"alice@example.com",
"inbound_groups":["platform-admins","system:authenticated"],
"inbound_extra":{"Remote-Client-IP":["10.42.1.3"]},"inbound_extra_omitted":1}

Records bridged from the Kubernetes libraries (client-go, apimachinery, the apiserver filters) carry component=k8s and no event_type. That absence is the filter: event_type present means the proxy said it, component=k8s means a library did.

Five rules govern the event set. They are what makes a saved query keep working across upgrades.

  1. Append-only. New event_type values may be added in any release. An existing value is never repurposed and never renamed in place; a retired value keeps its constant so the string is never reused.
  2. Adding a value does not bump schema_version. A query that matches one value is unaffected by the existence of a new one, so a new event is not a breaking change. schema_version moves only when field parsing breaks.
  3. The registry is the source. One entry per event, with its component, default level, mandatory fields and one-line summary, lives in the code and generates the table below; CI fails on a diff. How to change it is in development: log contract.
  4. Query on event_type, not msg. msg is static human text and may be reworded. event_type is the machine contract.
  5. component=k8s records have no event_type. They come from Kubernetes libraries through the klog bridge, keep their own wording, and are excluded from every rule above.

What changes between releases, and what each kind of change means for a saved query:

  • Adding an event_type value is allowed in any release and does not bump schema_version. A query for one value is unaffected by a new one.
  • Renaming a value after it has shipped is breaking. A record carries one event_type, so there is no overlap window and emitting both names double-counts. The recovery is: add the new value, stop the old one in the same release, keep the old constant marked retired so the string is never reused, publish an event_type IN (old, new) migration query, and announce it in the changelog. At least one minor release of notice, one major for anything under request.* or authn.*.
  • Retiring a value because its code path was removed is not breaking. Queries return nothing instead of failing. Keep the constant marked retired.
  • schema_version bumps only when field parsing breaks: a field removed or retyped, or a closed value set narrowed. Not for a new event, a new optional field, or a widened value set.

-v is the single verbosity knob. There is no independent --log-level.

LevelContentVisible at
ERRORThe process cannot serve correctly: audit backend start or flush failure, the API server unreachable for a SubjectAccessReview or TokenReview, an internal invariant breach, an unreadable CA.always
WARNDegraded state and anomalies: an issuer pending with a reason (on state change only), a forwarded header dropped, repeated reserved-identity attempts, header-cap attempts.always
INFOLifecycle (effective config and version, issuer configured and initialized, ready, serving, shutdown milestones, hook results) and the access record for every request, unsampled.-v=0
DEBUGPer-request internals: the auth path taken, cache hit or miss, the live SubjectAccessReview, impersonation header names, client cancellation.-v>=1

WARN and ERROR are never hidden, whatever -v says. Denials are INFO on the access record and are never rate-limited: a bad token must not be able to buy a warning line, and denial rates belong in metrics. Only request.anomaly.detected, request.headers.dropped and log.warning.suppressed are token-bucketed; when the bucket drops records, log.warning.suppressed reports warning_reason, suppressed_count and interval_seconds.

--logging-format selects json (the default) or text for the whole stream. Both flags are documented in the configuration reference.

43 registered values, generated from the registry. “Required” lists the fields that must be present beyond time, level, msg, schema_version, component and event_type; conditional fields are described in the summary.

event_typecomponentslevelrequiredsummary
audit.backend.failedauditERRORerror_messageThe audit backend did not start.
audit.backend.startedauditINFObackend_kindThe audit backend is running.
audit.flush.completedauditINFOduration_msThe pre-shutdown audit flush succeeded.
audit.flush.failedauditERRORerror_messageThe pre-shutdown audit flush failed.
authn.oidc.failedoidcDEBUGrequest_id, reasonThe OIDC authenticator rejected the token. The denial itself is INFO on request.access.decided.
authn.oidc.succeededoidcDEBUGrequest_id, issuer_nameThe OIDC authenticator accepted the token. Names the issuer that accepted it.
authn.token.missingtokenreviewDEBUGrequest_idNo bearer token was presented on the TokenReview path.
authn.tokenreview.completedtokenreviewDEBUGrequest_id, authenticatedA live TokenReview answered, with duration_ms. A cache hit is reported by cache.tokenreview.lookup instead.
authn.tokenreview.failedtokenreviewERRORrequest_id, reason, error_messageThe API server was unreachable or returned a status error. Carries reason=authentication_dependency_error.
authz.impersonation.resolvedsarDEBUGrequest_id, target_kind, target_nameThe whole impersonation sequence was allowed. A denial is request.access.decided with decision=deny and reason=impersonation_denied.
authz.sar.completedsarDEBUGrequest_id, decision, duration_ms, request_coalesced, target_kindA live or shared SubjectAccessReview returned. Fires once per impersonation header value, so one request can emit several.
authz.sar.failedsarERROR or DEBUGrequest_id, reason, error_messageThe SubjectAccessReview call failed. Carries reason=authorization_dependency_error, or reason=client_canceled at DEBUG when the client hung up before the review answered.
cache.sar.lookupsarDEBUGrequest_id, cache_resultOne SubjectAccessReview cache consultation. Carries decision on a hit, never the cache key.
cache.tokenreview.lookuptokenreviewDEBUGrequest_id, cache_resultOne TokenReview cache consultation. Carries authenticated on a hit, never the cache key.
log.warning.suppressedanyWARNwarning_reason, suppressed_count, interval_secondsToken-bucket summary of dropped warning records.
metrics.scrape.failedmetricsERRORerror_messageGathering or encoding the exposition failed; the scrape was answered with 500.
metrics.server.failedmetricsERRORerror_messageThe metrics HTTP server returned an error.
metrics.server.startedmetricsINFOaddressThe metrics listener is serving /metrics.
oidc.issuer.configuredoidcINFOissuer_name, issuer_countOnce per configured issuer at startup.
oidc.issuer.initializedoidcINFOissuer_name, issuer_state, ready_issuers, total_issuersAn issuer’s JWKS loaded. Carries issuer_state=initialized.
oidc.issuer.pendingoidcWARNissuer_name, issuer_state, pending_reason, ready_issuers, total_issuersThe pending set or a pending reason changed. Carries issuer_state=pending; not emitted on every scrape.
proxy.config.invalidstartupERRORreason, error_messageThe configuration cannot be used, including a CA bundle that cannot be read.
proxy.config.loadedstartupINFOversion, config_hash, issuer_count, readiness_modeThe effective non-secret configuration is fixed.
proxy.hook.completedshutdownINFOhook, duration_msOne record per pre-shutdown hook that finished.
proxy.hook.failedshutdownERRORhook, error_messageA pre-shutdown hook returned an error.
proxy.server.startedserverINFOaddressThe secure listener is serving.
proxy.server.stoppedserverINFOduration_msThe secure listener stopped.
proxy.shutdown.completedshutdownINFOduration_msListeners stopped, readiness stopped and pre-shutdown hooks finished.
proxy.shutdown.startedshutdownINFOsignalA termination signal was received. The forced exit is the same event with forced=true.
proxy.startup.failedstartupERRORerror_messageThe process could not reach serving after the logger was built; it exits non-zero without a second, unstructured error line.
readiness.proxy.readyreadinessINFOready_issuers, total_issuers, readiness_modeReadiness latched to ready.
readiness.server.failedreadinessERRORerror_messageThe readiness HTTP server returned an error.
request.access.decidedrequestINFOrequest_id, event, src_ip, path, http_method, auth_method, decisionAuthentication, authorization and proxy admission decided for a request. Carries event=AuSuccess|AuFail.
request.anomaly.detectedrequestWARNrequest_id, src_ip, reasonA rejection that indicates an exploit attempt or gross misconfiguration. Token-bucketed; the access record still carries the outcome.
request.handler.failedrequestERRORrequest_id, reason, error_messageThe proxy hit an internal error while handling a request; the access record carries reason=internal_error.
request.headers.droppedrequestWARNrequest_id, src_ip, dropped_headersX-Forwarded-For or X-Real-Ip removed for an untrusted peer, a fully trusted chain or a malformed hop. Carries forwarded_for_untrusted when the client sent an X-Forwarded-For; a client that sent only X-Real-Ip has no chain to report. Token-bucketed.
request.headers.rewrittenrequestDEBUGrequest_id, src_ip, forwarded_for_untrustedX-Forwarded-For collapsed to the resolved client IP on the trusted-proxy path. Diagnostic, not a warning: it is the trusted-proxy contract working as configured and fires on every request behind a trusted ingress.
request.impersonation.appliedrequestDEBUGrequest_id, outbound_user, impersonated_header_namesOutbound impersonation headers built. Header names only, never values.
request.impersonation.skippedrequestDEBUGrequest_id, skip_reasonImpersonation was disabled by flag, or the request took the TokenReview passthrough path.
request.response.completedrequestINFOrequest_id, http_status, duration_ms, terminationThe handler returned. The terminal record for every request, mirroring the audit stage ResponseComplete.
request.response.startedrequestINFOrequest_id, http_status, time_to_headers_msFirst final status on a long-running request; a forwarded 1xx does not start the response. Mirrors the audit stage ResponseStarted.
upstream.request.canceledupstreamDEBUGrequest_id, reasonThe client went away before the upstream response completed. Carries reason=client_canceled.
upstream.request.failedupstreamERROR or DEBUGrequest_id, reason, termination, error_messageThe reverse proxy transport failed. Carries reason=upstream_error and a classified termination; drops to DEBUG when the client canceled the request.

Status: frozen means the name and value format exist today and must not change — SIEM rules key on them. new means added by the structured-logging work.

src_ip and path are the only emitted names for the client address and the request path. No alias of either is emitted and no deprecation window is open. If an alias is ever added it goes in this table with a status of alias.

FieldTypeValues / formatStatusRecordsNotes
request_idstringUUID v4 minted by the proxy, max 64 charsnewall request recordsthe same value as the Audit-ID header sent upstream and echoed to the client
client_request_idstringinbound Audit-ID or X-Request-ID, printable ASCII, max 64 charsnewrequest.access.decidedpresent only when a valid inbound value existed; never used as request_id unless the peer is a trusted proxy
eventstringAuSuccess, AuFailfrozenrequest.access.decidedthe SIEM contract
src_ipstringIPv4 or IPv6, trusted-proxy resolvedfrozenall request recordsthe raw peer address with its port is never logged
forwarded_for_untrustedstringraw X-Forwarded-For chain, sanitizedfrozenrequest.access.decided, request.headers.*forensic, not identity
pathstringURL path, query string stripped, sanitizedfrozenrequest.access.decided
http_methodstringGET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, CONNECTnewrequest.access.decided
k8s_verbstringget, list, watch, create, update, patch, delete, deletecollection, proxy, connectnewrequest.access.decidedfrom the request-info resolver; absent on non-resource paths
k8s_api_groupstringAPI group, empty string for the core groupnewrequest.access.decided
k8s_resourcestringresource name, for example podsnewrequest.access.decided
k8s_subresourcestringfor example exec, log, portforwardnewrequest.access.decidedabsent when none
k8s_namespacestringnamespacenewrequest.access.decidedabsent for cluster-scoped requests
k8s_namestringobject name, max 256 charsnewrequest.access.decidedabsent on list and watch
auth_methodstringoidc, tokenreview, nonenewrequest.access.decidednone is the no-token 401 path
issuer_namestringconfigured issuer name, max 256 charsnewrequest.access.decided, oidc.issuer.*never the full issuer URL
reasonstringclosed set, see reason vocabulariesnewrequest.access.decided on AuFail, and on error records
decisionstringallow, denynewrequest.access.decided, cache.sar.lookup, authz.sar.completedauthorization outcome
inbound_userstringusername, sanitized, max 256 charsfrozenrequest.access.decided
inbound_uidstringUID, sanitizedfrozenrequest.access.decidedabsent when empty
inbound_groupsarray of stringfirst 32 groups, sanitizedfrozenrequest.access.decidedthe cap is new
inbound_groups_omittedintgroups beyond the capnewrequest.access.decidedabsent when zero
inbound_extraobjectallowlisted impersonation extras onlyfrozenrequest.access.decidedoriginaluser.jetstack.io-extra is no longer on the allowlist
inbound_extra_omittedintcount of extras not loggedfrozenrequest.access.decided
outbound_user, outbound_uid, outbound_groups, outbound_groups_omitted, outbound_extra, outbound_extra_omittedas the inbound_* fieldsas the inbound_* fieldsfrozen (the caps are new)request.access.decided when impersonatingcompare with inbound_* to see who acted as whom
target_kindstringuser, group, uid, extra, serviceaccount, unknownnewrequest.access.decided on impersonation_denied, authz.sar.completed, authz.impersonation.resolvedreplaces parsing the error text
target_namestringimpersonation target, sanitized, max 256 charsnewsame as target_kind
http_statusintHTTP status written to the clientnewrequest.response.started, request.response.completeda 200 implied by the first Write, or by a handler that returned without writing, is recorded as 200; 0 only when no response went out, because the connection was hijacked or the handler aborted before writing
time_to_headers_msintmilliseconds from request start to the first final statusnewrequest.response.startedlong-running requests only; a forwarded 1xx does not start the response
duration_msintmilliseconds from request start to handler returnnewrequest.response.completed, authz.sar.completed, authn.tokenreview.completed
response_bytesintbytes written to the clientnewrequest.response.completedabsent when the connection was hijacked
terminationstringnormal, client_cancel, upstream_reset, upstream_timeout, proxy_error, hijacked, panicnewrequest.response.completed, upstream.request.failed
error_messagestringsanitized, max 512 charsnewDEBUG records, ERROR records and internal_errornever at INFO or WARN for a client-caused failure
dropped_headersarray of stringx-forwarded-for, x-real-ipnewrequest.headers.dropped
skip_reasonstringdisabled, passthroughnewrequest.impersonation.skipped
impersonated_header_namesarray of stringImpersonate-User, Impersonate-Group, Impersonate-Uid, Impersonate-Extra-<key>newrequest.impersonation.appliedheader names only, never values
FieldTypeValues / formatRecordsNotes
cache_resultstringhit, miss, bypasscache.sar.lookup, cache.tokenreview.lookupbypass covers a disabled cache and an oversized SAR spec; miss includes expired
request_coalescedbooltrue, falseauthz.sar.completedsingleflight shared one live check between requests
decisionstringallow, denycache.sar.lookup, authz.sar.completedon a SAR cache hit
authenticatedbooltrue, falseauthn.tokenreview.completed, cache.tokenreview.lookupon a TokenReview cache hit
duration_msintlive call durationauthz.sar.completed, authn.tokenreview.completedabsent on a cache hit

Cache keys, token hashes and the serialized SubjectAccessReview spec are never logged. The cache identity is in the event type, not in a field.

FieldTypeValues / formatRecords
versionstringbuild versionproxy.config.loaded
config_hashstringhash of the effective non-secret configurationproxy.config.loaded
issuer_countintconfigured issuersproxy.config.loaded, oidc.issuer.configured
issuer_statestringpending, initializedoidc.issuer.initialized, oidc.issuer.pending
pending_reasonstringnot_initialized, transient, erroroidc.issuer.pending
ready_issuers, total_issuersintcountsoidc.issuer.initialized, oidc.issuer.pending, readiness.proxy.ready
readiness_modestringany, allproxy.config.loaded, readiness.proxy.ready
hookstringpre-shutdown hook nameproxy.hook.completed, proxy.hook.failed
warning_reasonstringthe suppressed record’s reasonlog.warning.suppressed
suppressed_countintrecords dropped in the intervallog.warning.suppressed
interval_secondsintsummary intervallog.warning.suppressed
addressstringbound listen addressproxy.server.started
signalstringSIGTERM, SIGINTproxy.shutdown.started
forcedbooltrue on the final signalproxy.shutdown.started, absent otherwise
backend_kindstringaudit backend typeaudit.backend.started

reason is a closed set, but there are two of them and they never mix.

Request failures — on request.access.decided with event=AuFail, on request.response.completed when the request ended in an error, and on the authn.*, authz.*, upstream.* and request.handler.failed records:

unauthorized, reserved_identity, impersonation_denied, too_many_impersonation_values, no_username_claim, authentication_dependency_error, authorization_dependency_error, upstream_error, internal_error, client_canceled.

Startup configuration failures — on proxy.config.invalid only, a startup-scoped vocabulary that is deliberately disjoint from the request set so a query for a configuration problem can never match request traffic:

ca_file_unreadable, invalid_configuration.

ca_file_unreadable is emitted when the CA bundle named by --oidc-ca-file cannot be read; invalid_configuration is reserved for an effective configuration the process refuses.

Bearer tokens, Authorization and Cookie values, any header not on the allowlist, request and response bodies, cache keys, arbitrary claims and extras, configured extra-header values, the User-Agent header, full issuer URLs, and raw peer addresses with ports.

The log stream is the only place the process reports its own failures once the root logger exists. A startup error after that point (an unreadable kubeconfig, a certificate that does not load, a port already bound) emits proxy.startup.failed with error_message and the process exits 1 without printing a second, unstructured line. Only a failure before the logger can be built, such as an unknown flag or an invalid --logging-format, is printed to stderr as plain text, because there is nothing else to report through yet.

On shutdown the audit backend is flushed before the process exits, and the outcome is logged. With the backends bundled from k8s.io/apiserver (log, and the buffered webhook) a lost flush is visible as a bridged component=k8s ERROR record, audit.flush.completed still follows with duration_ms, and the process exits 0: those backends do not report an error from Shutdown(), because the upstream audit.Backend interface has no return value there, and the buffered backend logs a delivery failure through the Kubernetes error handler instead. So with a bundled backend, alert on the component=k8s ERROR, not on the exit status.

A backend that implements the optional ShutdownErr() error extension gets stricter handling: a flush it reports as failed emits audit.flush.failed, propagates out of the pre-shutdown hooks as proxy.hook.failed, and makes the process exit non-zero, on the grounds that a dropped audit event for a request this process already served is not a warning to be tidied away. None of the bundled backends implement the extension today.

One request produces several records — an access decision, cache lookups, a SubjectAccessReview, a terminal request.response.completed. request_id joins them, and it reaches beyond this process:

  • The outermost filter mints a UUID v4 per request and sets it as the Audit-ID request header.
  • Both proxy audit chains read that header, so the proxy’s own audit events carry the same value instead of minting two.
  • The reverse proxy forwards the header upstream, so the kube-apiserver audit event’s auditID is the same string. One id spans the proxy log, the proxy audit log and the API server audit log.
  • The response carries Audit-ID back to the client, so a user can quote the id of the request they are complaining about.

In short: request_id = the Audit-ID header = the kube-apiserver auditID.

An inbound Audit-ID or X-Request-ID is not adopted as request_id unless the immediate peer is inside a configured --trusted-proxies network. A syntactically valid inbound value from an untrusted peer is kept as client_request_id on the access record — visible for correlation with an ingress, never authoritative. See trusted proxies and client IP.

Two decisions are recorded for a forwarded request, and they answer different questions:

FieldWho decidesQuestion answered
decision on the proxy’s access recordthe proxyWas the request admitted: token verified and any Impersonate-* headers authorized? allow means it was forwarded.
authorization.k8s.io/decision on the API server’s audit eventthe API serverWas the impersonated identity allowed to perform the action? allow or forbid.

decision=allow followed by an upstream 403 is a normal, healthy outcome: the proxy admitted the request and RBAC refused the action. The upstream result is http_status on request.response.completed and the annotation on the API server’s event. decision=deny means the proxy refused the request itself, so no API server audit event exists for it, and reason says why.

event=AuSuccess|AuFail and decision=allow|deny are the frozen contract SIEM rules already key on. event names the outcome, event_type names the record shape; AuSuccess is only ever emitted with decision=allow and AuFail only with decision=deny, and a test enforces the pairing.

Three questions, in three query languages. $POD is a proxy pod, $RID a request id.

Terminal window
kubectl -n kube-oidc-proxy logs "$POD" | grep "\"request_id\":\"$RID\""
{app="kube-oidc-proxy"} | json | request_id = "<request-id>"
index=kubernetes sourcetype=kube-oidc-proxy request_id="<request-id>"
| sort 0 _time
| table _time level event_type decision reason http_status duration_ms

The same id in the API server audit log finds the upstream half: auditID == "<request-id>".

Terminal window
kubectl -n kube-oidc-proxy logs "$POD" --since=1h \
| jq -r 'select(.event_type == "request.access.decided" and .decision == "deny") | .reason' \
| sort | uniq -c | sort -rn
sum by (reason) (
count_over_time(
{app="kube-oidc-proxy"} | json
| event_type = "request.access.decided" | decision = "deny" [1h]
)
)
index=kubernetes sourcetype=kube-oidc-proxy earliest=-1h
event_type="request.access.decided" decision="deny"
| stats count by reason
| sort -count

An issuer’s lifecycle is two record types: oidc.issuer.pending, emitted when the pending set or a pending reason changes, and oidc.issuer.initialized, emitted once its JWKS has loaded. Its current state is therefore the newest of the two per issuer_name, within one process; a query on pending records alone shows pending history and would miss the initialization that ended it.

Terminal window
kubectl -n kube-oidc-proxy logs "$POD" \
| jq -rs 'map(select(.event_type == "oidc.issuer.pending" or .event_type == "oidc.issuer.initialized"))
| group_by(.issuer_name) | map(last)
| .[] | "\(.issuer_name)\t\(.issuer_state)\t\(.pending_reason // "-")\t\(.ready_issuers)/\(.total_issuers)"'

In Loki a log query cannot keep only the newest line per issuer, so the line-level form below is the lifecycle history, newest first, not the current state; read the last line for each issuer_name and pod.

{app="kube-oidc-proxy"} | json | event_type =~ "oidc.issuer.(pending|initialized)"
| line_format "{{.issuer_name}} {{.issuer_state}} {{.pending_reason}} {{.ready_issuers}}/{{.total_issuers}}"

For the current state as a table, use the fact that an issuer never returns to pending within one process: every pod and issuer pair that has logged oidc.issuer.initialized is ready, and a configured issuer missing from the result is still pending.

sum by (pod, issuer_name) (
count_over_time({app="kube-oidc-proxy"} | json | event_type = "oidc.issuer.initialized" [$__range])
)
index=kubernetes sourcetype=kube-oidc-proxy event_type IN ("oidc.issuer.pending", "oidc.issuer.initialized")
| stats latest(issuer_state) AS state latest(pending_reason) AS pending_reason latest(_time) AS last_seen by pod, issuer_name

Group by pod as well as issuer when several replicas run: each process initializes its issuers independently. See multi-issuer readiness.

kubectl logs deploy/<name> reads one pod. Select by label to merge all replicas, and lift the per-pod line cap kubectl applies to a selector:

Terminal window
kubectl -n kube-oidc-proxy logs -l app.kubernetes.io/name=kube-oidc-proxy \
--since=15m --tail=-1 --prefix

Loki and Splunk already see every pod; the equivalent there is a label or sourcetype match, which the queries above use.

An access decision and a terminal request.response.completed are two records of one request; a kubectl --as request at -v=1 adds one cache.sar.lookup per impersonation header value. Grouping on request_id puts the decision, the upstream status and the cache outcome on the same line.

Terminal window
kubectl -n kube-oidc-proxy logs -l app.kubernetes.io/name=kube-oidc-proxy --since=15m --tail=-1 \
| jq -rs 'map(select(.request_id != null)) | group_by(.request_id)
| map((map(select(.event_type == "request.access.decided"))[0] // {}) as $a
| (map(select(.event_type == "request.response.completed"))[0] // {}) as $r
| (map(select(.event_type == "cache.sar.lookup") | .cache_result) | join(",")) as $c
| select($a.time != null)
| [$a.time, $a.event, ($a.reason // "-"), ($a.inbound_user // "-"),
($a.k8s_verb // "-"), ($a.k8s_api_group // ""), ($a.k8s_resource // "-"),
($a.k8s_namespace // "-"), (if $c == "" then "-" else $c end),
(($r.http_status // "-") | tostring), $a.request_id] | @tsv)
| .[]'
index=kubernetes sourcetype=kube-oidc-proxy earliest=-15m request_id=*
| stats earliest(_time) AS _time values(event) AS event values(reason) AS reason
values(inbound_user) AS user values(k8s_verb) AS verb
values(k8s_api_group) AS api_group values(k8s_resource) AS resource
values(k8s_namespace) AS namespace values(cache_result) AS cache
values(http_status) AS status by request_id
| where isnotnull(event)
| sort _time

k8s_verb, k8s_api_group, k8s_resource and k8s_namespace are the request-info dimensions, in the vocabulary RBAC rules use: list rather than GET, and the group separate from the resource with the core group empty.

In LogQL there is no join across lines; filter on request_id for one request (above), or alert on request.response.completed with http_status and pull the decision by ID when a line needs explaining. A shell function that renders this as a table, with a worked example of what each row means, is in operations: watching requests.

The proxy’s field names are the contract; they are not renamed to Elastic Common Schema. This table is for whoever writes the ingest pipeline that copies them.

Proxy fieldECS fieldNotes
time@timestamp
levellog.level
msgmessage
componentlog.logger
event_typeevent.action
request_idtrace.idalso the kube-apiserver auditID; http.request.id if you prefer a transaction-scoped field
src_ipsource.ipthe resolved client IP
pathurl.path
http_methodhttp.request.method
http_statushttp.response.status_code
response_byteshttp.response.body.bytes
duration_msevent.durationevent.duration is nanoseconds — multiply by 1,000,000
time_to_headers_ms—no ECS equivalent; keep as a custom field
eventevent.outcomeAuSuccess → success, AuFail → failure; ECS also allows unknown. Map from event, not from decision
reasonevent.reason
inbound_useruser.namethe authenticated identity
inbound_groupsuser.roles
outbound_useruser.effective.nameECS’s user.effective.* reusable set is exactly the impersonation case
outbound_groupsuser.effective.roles
error_messageerror.message
k8s_verb, k8s_resource, k8s_namespace, …—no ECS equivalent; keep as custom fields under your own namespace
  • Never logged: bearer tokens, Authorization and Cookie values, any header not on the allowlist, request and response bodies, cache keys, arbitrary claims and extras, configured extra-header values, the User-Agent header, full issuer URLs.
  • originaluser.jetstack.io-extra is not on the loggable extras allowlist. That extras existed is reported as a bounded count (inbound_extra_omitted, outbound_extra_omitted), never as content.
  • Every user-influenced string passes through the sanitize helper, which strips control characters, so nothing a client sends can inject a second record or fake a field.
  • Bounds: request_id 64 characters, error_message 512, identity strings 256, group lists capped at 32 with inbound_groups_omitted / outbound_groups_omitted reporting the drop.
  • Prefer a classified reason over raw error text at INFO and WARN. A truncated error_message appears only on internal failures and at DEBUG.
  • issuer_name is the configured name. Full issuer URLs are never logged.