Skip to content

Commit b8b739c

Browse files
authored
Merge pull request #48 from trick77/feat/splunk-friendly-logs
feat(logging): Splunk-friendly JSON logs + request correlation
2 parents 788f70f + ffb2957 commit b8b739c

11 files changed

Lines changed: 287 additions & 112 deletions

File tree

AGENTS.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,26 @@ If `docker ps` fails, ask the user to start OrbStack.
4848
- Tests use real Postgres via testcontainers, never SQLite. The `client` fixture in `tests/conftest.py` depends on `session_factory` which truncates tables per test.
4949
- `.pre-commit-config.yaml` runs ruff + pyright + uv-lock-check; expect CI to enforce the same.
5050

51+
## Logging & Splunk
52+
53+
- **One JSON object per line, on stdout.** OpenShift's Splunk Connect for Kubernetes (SCK) tails the container log; Splunk auto-extracts fields via `KV_MODE=json` for sourcetype `riptide:collector:json` (set as pod annotation in `openshift/collector/deployment.yaml`).
54+
- **Stdlib loggers (uvicorn, sqlalchemy, alembic) are bridged through structlog.** Do NOT add separate logging handlers or re-init `logging.basicConfig``configure_logging()` in `logging_config.py` is the single entry point.
55+
- **Splunk-reserved field names are forbidden as kwargs**: `source`, `sourcetype`, `host`, `index`, `time`, `_time`, `_raw`, `event`. The CI vendor field is `ci_system` (not `source`); the structlog event name lives in `msg` (renamed from `event`); severity lives in `log_level` (renamed from `level`). A runtime processor (`_strip_reserved`) namespaces accidental reserved kwargs under `splunk_<name>` as a safety net — do not rely on it; pick the right name from the start.
56+
- **Webhook handlers emit exactly one `msg=webhook_processed` log per request** with required fields `webhook_source ∈ {bitbucket,pipeline,argocd,noergler}`, `outcome ∈ {accepted,deduped,ignored,skipped}`, `delivery_id`, `team`. Source-specific fields go alongside (e.g. `app`, `revision`, `phase` for argocd). Include `delivery_id` even on `ignored`/`skipped` paths so triage has a key.
57+
- **`outcome=deduped`** is detected via `RETURNING delivery_id` on the `INSERT ... ON CONFLICT DO NOTHING` — a `None` scalar means the row already existed. Preserve this when adding new sources.
58+
- **Persist failures**: wrap the `async with session_factory()` block in `try/except Exception: logger.exception("webhook_persist_failed", ...); raise`. Never swallow.
59+
- **Access log** is emitted by the `access_log` middleware in `main.py` as `msg=http_request` with `request_id`, `method`, `path`, `status_code`, `duration_ms`. `request_id` is bound to contextvars so any log within the request inherits it. `/health` and `/ready` are silenced; uvicorn.access is set to WARNING (do not lower it).
60+
- **Splunk `props.conf` snippet** (owned by platform team, kept here for reference):
61+
```
62+
[riptide:collector:json]
63+
SHOULD_LINEMERGE = false
64+
LINE_BREAKER = ([\r\n]+)
65+
KV_MODE = json
66+
TIME_PREFIX = "timestamp":\s*"
67+
TIME_FORMAT = %Y-%m-%dT%H:%M:%S.%6NZ
68+
TRUNCATE = 0
69+
```
70+
5171
## OpenShift layout
5272

5373
`openshift/` is **suite-level**, structured per-component. The collector lives in `openshift/collector/`. When adding a new component:

openshift/collector/configmap-app.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ metadata:
44
name: riptide-collector-app
55
data:
66
RIPTIDE_LOG_LEVEL: "INFO"
7+
# Stamped on every log record as `env`. Override per overlay (intg / prod).
8+
RIPTIDE_ENV: "prod"
79
RIPTIDE_CONFIG_PATH: "/etc/riptide-collector/riptide.json"
810
RIPTIDE_TEAM_KEYS_PATH: "/etc/riptide-collector-team-keys/team-keys.json"
911
RIPTIDE_CONFIG_RELOAD_SECONDS: "30"

openshift/collector/deployment.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ spec:
1818
labels:
1919
app.kubernetes.io/name: riptide-collector
2020
app.kubernetes.io/component: collector
21+
annotations:
22+
# Splunk Connect for Kubernetes reads these per-pod annotations.
23+
# The sourcetype must have `KV_MODE=json` configured in props.conf
24+
# so Splunk auto-extracts our JSON log fields. The index annotation
25+
# can be overridden per overlay.
26+
splunk.com/sourcetype: "riptide:collector:json"
2127
spec:
2228
securityContext:
2329
runAsNonRoot: true

src/riptide_collector/auth.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,8 @@ async def verify_hmac_signature(
125125
signature_ok = _verify_hmac_sha256(verify_secret, raw, x_hub_signature)
126126
if secret is None or not signature_ok:
127127
logger.warning(
128-
f"{source}_hmac_rejected",
128+
"hmac_rejected",
129+
webhook_source=source,
129130
team=team,
130131
has_secret=secret is not None,
131132
has_signature=bool(x_hub_signature),

src/riptide_collector/logging_config.py

Lines changed: 70 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,41 +4,91 @@
44

55
import structlog
66

7+
from riptide_collector import __version__
78

8-
class _HealthCheckAccessFilter(logging.Filter):
9-
_silent_paths = frozenset({"/health", "/ready"})
9+
# Field names Splunk treats as built-in input metadata. Using them as JSON
10+
# keys causes Splunk to silently overwrite our values with the forwarder's
11+
# (`source`, `host`, `index`, `sourcetype`, `time`/`_time`/`_raw`), or to
12+
# double-extract (`event`). Keep them off the wire.
13+
_SPLUNK_RESERVED = frozenset(
14+
{"source", "sourcetype", "host", "index", "time", "_time", "_raw", "event"}
15+
)
1016

11-
def filter(self, record: logging.LogRecord) -> bool:
12-
args = record.args
13-
if isinstance(args, tuple) and len(args) >= 3:
14-
path = args[2]
15-
if isinstance(path, str) and path.split("?", 1)[0] in self._silent_paths:
16-
return False
17-
return True
17+
_SERVICE_NAME = "riptide-collector"
1818

1919

20-
def configure_logging(level: str = "INFO") -> None:
20+
def _make_service_metadata_processor(env: str):
21+
def _add(_logger: Any, _name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
22+
del _logger, _name
23+
event_dict.setdefault("service", _SERVICE_NAME)
24+
event_dict.setdefault("version", __version__)
25+
event_dict.setdefault("env", env)
26+
return event_dict
27+
28+
return _add
29+
30+
31+
def _rename_level(_logger: Any, _name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
32+
del _logger, _name
33+
if "level" in event_dict and "log_level" not in event_dict:
34+
event_dict["log_level"] = event_dict.pop("level")
35+
return event_dict
36+
37+
38+
def _strip_reserved(_logger: Any, _name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
39+
del _logger, _name
40+
# `msg` is set by EventRenamer, `log_level` by _rename_level — both
41+
# already safe. This pass catches accidental kwargs (e.g. `source="jenkins"`)
42+
# and namespaces them under `splunk_<name>` so the value survives without
43+
# colliding with Splunk's built-in field of the same name.
44+
for key in list(event_dict.keys()):
45+
if key in _SPLUNK_RESERVED:
46+
event_dict[f"splunk_{key}"] = event_dict.pop(key)
47+
return event_dict
48+
49+
50+
def configure_logging(level: str = "INFO", env: str = "dev") -> None:
2151
log_level = getattr(logging, level.upper(), logging.INFO)
22-
logging.basicConfig(
23-
format="%(message)s",
24-
stream=sys.stdout,
25-
level=log_level,
26-
)
27-
logging.getLogger("uvicorn.access").addFilter(_HealthCheckAccessFilter())
2852

29-
processors: list[Any] = [
53+
shared_processors: list[Any] = [
3054
structlog.contextvars.merge_contextvars,
3155
structlog.processors.add_log_level,
3256
structlog.processors.TimeStamper(fmt="iso", utc=True),
3357
structlog.processors.StackInfoRenderer(),
3458
structlog.processors.format_exc_info,
35-
structlog.processors.JSONRenderer(),
59+
_make_service_metadata_processor(env),
60+
structlog.processors.EventRenamer("msg"),
61+
_rename_level,
62+
_strip_reserved,
3663
]
3764

65+
# Bridge: route stdlib logs (uvicorn, sqlalchemy, alembic) through the
66+
# same JSON pipeline so Splunk sees one schema only.
67+
formatter = structlog.stdlib.ProcessorFormatter(
68+
foreign_pre_chain=shared_processors,
69+
processors=[
70+
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
71+
structlog.processors.JSONRenderer(),
72+
],
73+
)
74+
handler = logging.StreamHandler(sys.stdout)
75+
handler.setFormatter(formatter)
76+
root = logging.getLogger()
77+
root.handlers = [handler]
78+
root.setLevel(log_level)
79+
80+
# uvicorn's access log becomes redundant once our middleware emits
81+
# http_request; keep only warnings/errors from it.
82+
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
83+
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
84+
3885
structlog.configure(
39-
processors=processors,
86+
processors=[
87+
*shared_processors,
88+
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
89+
],
4090
wrapper_class=structlog.make_filtering_bound_logger(log_level),
41-
logger_factory=structlog.PrintLoggerFactory(),
91+
logger_factory=structlog.stdlib.LoggerFactory(),
4292
cache_logger_on_first_use=True,
4393
)
4494

src/riptide_collector/main.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import time
2+
import uuid
13
from contextlib import asynccontextmanager
24
from typing import Any
35

4-
from fastapi import FastAPI
6+
import structlog
7+
from fastapi import FastAPI, Request, Response
58

69
from riptide_collector import __version__
710
from riptide_collector.auth import make_hmac_dependency, make_team_bearer_dependency
@@ -14,6 +17,8 @@
1417

1518
logger = get_logger(__name__)
1619

20+
_SILENT_PATHS = frozenset({"/health", "/ready"})
21+
1722

1823
class StartupValidationError(RuntimeError):
1924
pass
@@ -37,7 +42,7 @@ def _cross_validate(config: RiptideConfigStore, team_keys: TeamKeysStore) -> Non
3742

3843
def create_app(settings: Settings | None = None) -> FastAPI:
3944
settings = settings or load_settings()
40-
configure_logging(settings.log_level)
45+
configure_logging(settings.log_level, env=settings.env)
4146

4247
config = RiptideConfigStore(settings.config_path)
4348
team_keys = TeamKeysStore(settings.team_keys_path)
@@ -76,6 +81,34 @@ async def lifespan(_app: FastAPI): # pyright: ignore[reportUnusedFunction]
7681
lifespan=lifespan,
7782
)
7883

84+
@app.middleware("http")
85+
async def access_log( # pyright: ignore[reportUnusedFunction]
86+
request: Request, call_next: Any
87+
) -> Response:
88+
# Liveness/readiness checks fire every few seconds; logging them
89+
# buries real traffic in Splunk. Pass through unobserved.
90+
if request.url.path in _SILENT_PATHS:
91+
return await call_next(request)
92+
request_id = request.headers.get("x-request-id") or uuid.uuid4().hex
93+
structlog.contextvars.bind_contextvars(
94+
request_id=request_id,
95+
method=request.method,
96+
path=request.url.path,
97+
)
98+
started = time.perf_counter()
99+
status_code = 500
100+
try:
101+
response: Response = await call_next(request)
102+
status_code = response.status_code
103+
return response
104+
finally:
105+
logger.info(
106+
"http_request",
107+
status_code=status_code,
108+
duration_ms=round((time.perf_counter() - started) * 1000, 1),
109+
)
110+
structlog.contextvars.clear_contextvars()
111+
79112
app.include_router(health.make_router(config, session_factory, team_keys, any_auth))
80113
app.include_router(bitbucket.make_router(config, session_factory, bitbucket_hmac))
81114
app.include_router(pipeline.make_router(session_factory, pipeline_auth))

src/riptide_collector/routers/argocd.py

Lines changed: 47 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -35,56 +35,72 @@ async def argocd_webhook( # pyright: ignore[reportUnusedFunction]
3535
revision = lower(event.revision)
3636
environment = parse_environment(event.destination_namespace)
3737

38+
# Compute the dedup key unconditionally so the `ignored` log line
39+
# carries it too — Triage starts from `delivery_id`.
40+
started_repr = event.started_at.isoformat() if event.started_at else "unknown"
41+
delivery_id = (
42+
f"{event.app_name}#{revision}#{started_repr}#{event.operation_phase or 'unknown'}"
43+
)
44+
3845
ignored_stages = config.get().environments.ignored_stages
3946
if environment is not None and environment in ignored_stages:
4047
logger.info(
41-
"argocd_event_ignored",
48+
"webhook_processed",
49+
webhook_source="argocd",
50+
outcome="ignored",
51+
reason="stage_in_ignored_stages",
52+
delivery_id=delivery_id,
4253
app=event.app_name,
54+
revision=revision,
55+
phase=event.operation_phase,
4356
environment=environment,
4457
destination_namespace=event.destination_namespace,
4558
team=caller_team,
4659
)
4760
return {"status": "ignored"}
4861

49-
# Stable dedup key: started_at is fixed for a sync attempt; phase varies
50-
# across the lifecycle (Running → Succeeded/Failed) and SHOULD produce
51-
# distinct rows. finished_at is excluded — it can drift between retries
52-
# of the same phase and would cause duplicates.
53-
started_repr = event.started_at.isoformat() if event.started_at else "unknown"
54-
delivery_id = (
55-
f"{event.app_name}#{revision}#{started_repr}#{event.operation_phase or 'unknown'}"
56-
)
57-
58-
async with session_factory() as session:
59-
stmt = (
60-
pg_insert(ArgoCDEvent)
61-
.values(
62-
delivery_id=delivery_id,
63-
app_name=event.app_name,
64-
revision=revision,
65-
sync_status=event.sync_status,
66-
operation_phase=event.operation_phase,
67-
started_at=event.started_at,
68-
finished_at=event.finished_at,
69-
occurred_at=event.finished_at or event.started_at or datetime.now(UTC),
70-
team=caller_team,
71-
destination_namespace=event.destination_namespace,
72-
environment=environment,
73-
payload=raw,
62+
try:
63+
async with session_factory() as session:
64+
stmt = (
65+
pg_insert(ArgoCDEvent)
66+
.values(
67+
delivery_id=delivery_id,
68+
app_name=event.app_name,
69+
revision=revision,
70+
sync_status=event.sync_status,
71+
operation_phase=event.operation_phase,
72+
started_at=event.started_at,
73+
finished_at=event.finished_at,
74+
occurred_at=event.finished_at or event.started_at or datetime.now(UTC),
75+
team=caller_team,
76+
destination_namespace=event.destination_namespace,
77+
environment=environment,
78+
payload=raw,
79+
)
80+
.on_conflict_do_nothing(index_elements=["delivery_id"])
81+
.returning(ArgoCDEvent.delivery_id)
7482
)
75-
.on_conflict_do_nothing(index_elements=["delivery_id"])
83+
inserted = (await session.execute(stmt)).scalar_one_or_none()
84+
await session.commit()
85+
except Exception:
86+
logger.exception(
87+
"webhook_persist_failed",
88+
webhook_source="argocd",
89+
delivery_id=delivery_id,
90+
team=caller_team,
7691
)
77-
await session.execute(stmt)
78-
await session.commit()
92+
raise
7993

8094
logger.info(
81-
"argocd_event_received",
95+
"webhook_processed",
96+
webhook_source="argocd",
97+
outcome="accepted" if inserted is not None else "deduped",
8298
delivery_id=delivery_id,
8399
app=event.app_name,
84100
revision=revision,
85101
phase=event.operation_phase,
86-
team=caller_team,
87102
environment=environment,
103+
team=caller_team,
88104
)
89105
return {"status": "accepted"}
90106

0 commit comments

Comments
 (0)