Ask your logs what happened.
$ make demo
raglogs analyzes a bounded time window of logs and produces a short incident explanation backed by evidence.
It is designed for answering one question quickly:
What happened, why do you think that, and what evidence supports it?
grep finds lines. Datadog shows dashboards. raglogs explains incidents.
- The killer commands
- Why raglogs
- Quick start
- Installation
- Commands
- Configuration
- LLM integration
- Log formats
- How it works
- HTTP API
- Web UI
- Development
- Roadmap
raglogs explain --since 2h╭──────────────────────────────────────────────────────── raglogs explain ─────────────────────────────────────────────────────────╮
│ Incident summary │
│ │
│ Window: 2026-03-12T22:33:30 to 2026-03-12T23:33:30 │
│ │
│ Services affected: billing-worker, api │
│ │
│ Primary issue: A surge of 184 Stripe signature verification failures occurred in the billing-worker service at the │
│ /webhooks/stripe endpoint, starting about 2 minutes after deployment of billing-worker version v2.4.1. │
│ │
│ Secondary effects: Following the primary failures, the api service experienced 39 checkout requests returning 500 Internal Server │
│ Errors due to upstream billing errors, along with 25 checkout requests showing high latency. Additionally, billing-worker logged │
│ webhook retry attempts for failed events. │
│ │
│ Likely trigger: Deployment of billing-worker version v2.4.1 at 22:38:29, immediately followed by application start, appears to │
│ have introduced the Stripe signature verification failures. │
│ │
│ Confidence: high │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
raglogs timeline --since 2h 22:38:29 deploy Deploy completed for billing-worker version v2.4.1 · deployment-controller
22:38:30 startup Application started billing-worker v2.4.1 on port 8080 · billing-worker
22:40:31 error ↑ Stripe signature verification failed for endpoint /webhooks/stripe
184 events · billing-worker · 49 min span
22:42:00 effect POST /api/checkout 500 Internal Server Error — upstream billing error
39 events · api · 48 min span
22:42:50 effect Webhook retries (2 retry events)
2 events · billing-worker
22:45:29 effect POST /api/checkout 200 OK latency=<duration> (high latency detected)
25 events · api · 44 min span
raglogs compare --since 30m --baseline 24hIncident comparison
Window A (now): 2026-03-16 15:17:42 UTC → 2026-03-16 15:47:42 UTC
Window B (baseline): 2026-03-15 15:17:42 UTC → 2026-03-15 15:47:42 UTC
New error clusters
+ Stripe signature verification failed for endpoint /webhooks/stripe 86 events
+ POST /api/checkout 500 Internal Server Error — upstream billing error 20 events
+ Webhook retries (24 distinct events, 24 total) 24 events
+ Webhook queue growing 13 events
Triggers in A not seen in B
+⚡ Deploy completed for billing-worker version v2.4.1 · deployment-controller
raglogs ask 'why did stripe fail?'╭─────────────────────────────────────────────────────────── raglogs ask ───────────────────────────────────────────────────────────╮
│ Stripe failed because the signature verification for incoming webhook requests to the /webhooks/stripe endpoint failed │
│ repeatedly. This caused the billing-worker service to reject or fail processing Stripe webhook events, likely disrupting payment │
│ or billing workflows. The errors were consistently observed between 22:54 and 23:30 UTC on 2026-03-12. │
│ │
│ Key supporting evidence: │
│ - 500 errors logged with the message "Stripe signature verification failed for endpoint /webhooks/stripe" │
│ - Errors occurred in the billing-worker service │
│ - Time window of errors: 2026-03-12T22:54:49 to 2026-03-12T23:30:29 UTC │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
explain answers what happened.
timeline shows how it unfolded.
compare shows what changed.
Together they work like git log, git blame, git diff — but for incidents.
All three outputs are fully deterministic. No LLM required.
Most log tools are good at search and filtering. raglogs is built for a different job: taking a bounded time window and explaining it.
The problem with raw LLM approaches
Sending thousands of log lines to an LLM produces vague summaries, hallucinated causes, and no grounding in actual counts or timing. Context windows fill up. Results are inconsistent.
What raglogs does instead
- Normalizes log messages to remove dynamic noise (UUIDs, IDs, IPs, timestamps)
- Fingerprints normalized messages into stable cluster keys
- Groups logs into clusters by fingerprint
- Compares cluster volumes against a configurable baseline window
- Detects trigger events (deploys, restarts, config reloads)
- Assembles a structured evidence packet from actual counts, timing, and baseline deltas
- Either passes that evidence to an LLM for polish, or renders it with deterministic templates
The LLM never sees raw logs. It only sees curated facts. The explanation is grounded in evidence, not inference.
Prerequisites: Docker, Python 3.10+
# Clone and install
git clone https://github.com/LAA-Software-Engineering/raglogs
cd raglogs
pip install -e .
# Start Postgres with pgvector
docker compose up postgres -d
# Initialize schema
raglogs init
# Run the demo
raglogs ingest ./sample_data/sample_incident
raglogs explain --since 1h
raglogs timeline --since 2h
raglogs compare --since 30m --baseline 24h
raglogs ask 'why did stripe fail?'Or with Make:
make demoRequirements
- Python 3.10+
- PostgreSQL 14+ with the pgvector extension
- Docker (optional, for the bundled Compose setup)
Install
pip install -e .Configure
cp .env.example .env
# Edit .env — set DB_URL at minimumInitialize the database
raglogs initThis runs Alembic migrations and creates all required tables, including the vector extension for pgvector.
Initializes local configuration and runs database migrations.
raglogs init
raglogs init --db-url postgresql+psycopg://user:pass@host/raglogs
raglogs init --no-migrate # skip migrationsIngests logs into the database from local files, a pull adapter (CloudWatch, Datadog, Loki), or Kubernetes log exports. File mode supports JSON and plain-text formats, single files, directories, and glob patterns.
raglogs ingest ./logs/app.log
raglogs ingest ./logs/
raglogs ingest ./logs/*.log
raglogs ingest ./logs/ --recursive
raglogs ingest ./logs/ --service api --env production
raglogs ingest ./logs/ --format json
# CloudWatch Logs (AWS credential chain; no keys on the CLI)
raglogs ingest --adapter cloudwatch --param log_group=/aws/lambda/my-service --since 1h
# Datadog Logs Search API (keys via env; see Configuration)
raglogs ingest --adapter datadog --param query='service:billing-worker status:error' --since 1hKubernetes log exports (--adapter k8s) — concatenated kubectl logs, Fluent Bit / Vector JSON lines, CRI (kubelet) node logs, .gz files, and tarballs. Namespace / pod / container map to environment / host / service.
# kubectl capture (prefix + timestamps give pod/container and event time)
kubectl logs -n production -l app=billing-worker --all-containers \
--prefix --timestamps --since=1h > /tmp/billing-export.log
raglogs ingest --adapter k8s /tmp/billing-export.log
# kubelet /var/log/pods dump (path supplies namespace, pod, container)
raglogs ingest --adapter k8s --recursive ./var/log/pods
raglogs ingest --adapter k8s ./node-logs.tar.gz| Flag | Description |
|---|---|
--recursive / -r |
Recurse into subdirectories |
--source-name |
Logical name for this ingestion source |
--service |
Default service name when not in logs |
--env |
Default environment |
--format |
json, text, or auto (default) |
--with-embeddings |
Persist pgvector embeddings on log_embeddings for semantic ask (requires EMBEDDINGS_PROVIDER) |
--adapter |
file (default), cloudwatch, datadog, loki, or k8s |
--param |
Adapter param as key=value (repeatable) |
--since |
Window for pull adapters, e.g. 30m, 1h, 24h (default last 1h) |
--from / --to |
Explicit ISO 8601 window bounds (pull adapters) |
--resume-job |
Prior ingestion job UUID to resume pagination cursors from |
--scope |
Isolation scope (CLI default default). Service requests require a resolvable scope. |
Loki
Pull a bounded window from Grafana Loki via LogQL — no intermediate files.
Auth and the Loki origin come from env (LOKI_*); tenant and query can be
passed per ingest. Stream labels are mapped onto service / environment /
host (app/service/job, namespace/env, pod/instance).
query_range's limit is global across matching streams. Paginating by
advancing start to the latest timestamp in a full page can skip earlier
lines from other streams in a wide selector — prefer a narrow LogQL query
when completeness matters.
export LOKI_URL=http://localhost:3100
raglogs ingest --adapter loki --param query='{app="api"}' --since 1h
raglogs ingest --adapter loki --param query='{namespace="prod"}' \
--from 2026-03-12T22:00:00+00:00 --to 2026-03-12T22:30:00+00:00curl -X POST http://localhost:8000/v1/ingestions \
-H "Content-Type: application/json" \
-d '{"adapter":"loki","params":{"query":"{app=\"api\"}"},"since":"1h"}'| Param | Description |
|---|---|
query / queries |
LogQL selector (required unless LOKI_QUERY is set) |
tenant |
Override LOKI_TENANT (X-Scope-OrgID) |
limit |
Page size for query_range (default 5000, Loki max) |
Output
Ingestion complete
Files processed: 3
Lines read: 464
Parsed logs: 461
Skipped/errors: 3
Services detected: api, billing-worker, deployment-controller
Duration: 0.4s
The main command. Analyzes a time window, clusters the logs, compares against a baseline, and produces a structured incident summary.
raglogs explain --since 30m
raglogs explain --since 2h --service billing-worker
raglogs explain --from 2026-03-12T22:00:00Z --to 2026-03-12T22:30:00Z
raglogs explain --since 1h --no-llm
raglogs explain --since 1h --format json
raglogs explain --since 1h --format markdown
raglogs explain --since 1h --format markdown > postmortem.md
raglogs explain --since 1h --baseline-window 7d| Flag | Description |
|---|---|
--since |
Relative window: 30m, 1h, 24h, 7d |
--from |
Start of window (ISO 8601) |
--to |
End of window (ISO 8601) |
--service |
Filter to one service |
--env |
Filter to one environment |
--no-llm |
Skip LLM, use deterministic templates |
--max-clusters |
Max clusters to analyze (default: 10) |
--baseline-window |
How far back to compare (default: 24h) |
--format |
text, json, or markdown |
Markdown incident report
--format markdown writes a paste-ready GitHub-flavored markdown file for tickets and postmortems. Redirect stdout to save it:
raglogs explain --since 1h --format markdown > postmortem.mdThe report includes:
- Title (from the primary cluster message, or the first useful summary line)
- Metadata: window (ISO start/end + duration), services, environment (if filtered), total logs, confidence, mode (
rules/llm) - Summary (the same explanation text as
text/ JSON — not re-parsed) - Primary cluster (fingerprint, count, importance, representative message) when present
- Secondary clusters and trigger candidates when present
- Evidence bullets
- A Reproduce footer with the exact
raglogs explain ... --format markdowninvocation
Output is written as raw markdown on stdout (Rich markup is not applied), so shell redirection stays valid GFM. Progress and errors go to stderr.
Output structure (default --format text)
Incident summary
Window: ...
Services affected: ...
Primary issue: ...
Secondary effects: ...
Likely trigger: ...
Evidence:
- ...
Confidence: low | medium | medium-high | high
Confidence is computed from cluster volume, baseline change ratio, trigger correlation, secondary cluster agreement, and service spread. It is never invented.
No-LLM mode produces the same structure from deterministic templates. Slightly less polished, zero hallucination risk, works fully offline.
Reconstructs the causal sequence of events in an incident window. Shows deploys, service restarts, the primary error spike, downstream effects, and system-level symptoms — sorted chronologically and grouped by causal role.
raglogs timeline --since 30m
raglogs timeline --since 2h
raglogs timeline --from 2026-03-12T22:00:00Z --to 2026-03-12T22:30:00Z
raglogs timeline --since 2h --service billing-worker
raglogs timeline --since 1h --format json| Flag | Description |
|---|---|
--since |
Relative window: 30m, 1h, 24h, 7d |
--from |
Start of window (ISO 8601) |
--to |
End of window (ISO 8601) |
--service |
Filter to one service |
--env |
Filter to one environment |
--format |
text or json |
Event categories
| Label | Meaning |
|---|---|
deploy |
Deploy, release, or rollout event |
startup |
Service start or port binding |
trigger |
Other pre-error event (config change, migration) |
error ↑ |
Primary error cluster — the root cause |
effect |
Downstream failure caused by the primary error |
symptom |
System-level degradation (queue growth, backlog) |
Output
Incident timeline 2026-03-12 21:58:00 UTC → 2026-03-12 23:58:00 UTC
21:58:14 deploy Deploy completed for billing-worker version v2.4.1 · deployment-controller
21:58:15 startup Application started billing-worker v2.4.1 on port 8080 · billing-worker
22:00:10 error ↑ Stripe signature verification failed for endpoint /webhooks/stripe
184 events · billing-worker · 49 min span
22:01:27 effect POST /api/checkout 200 OK latency=<duration> (high latency detected)
25 events · api · 43 min span
22:01:49 effect Webhook retries (2 retry events)
2 events · billing-worker
22:02:56 effect POST /api/checkout 500 Internal Server Error — upstream billing error
39 events · api · 45 min span
22:11:25 symptom Webhook queue growing, 251 events pending processing
2 events · billing-worker · 30 min span
Point-in-time events (deploys, startups) show the service inline. Volumetric events show a sub-line with event count, service, and cluster duration. Blank lines separate events more than 60 seconds apart. Repeated webhook retry events are deduplicated into a single line.
No LLM required. The timeline is assembled entirely from cluster timestamps and causal classification.
Diffs two time windows by their cluster sets. Shows exactly which error patterns appeared, disappeared, intensified, or resolved between a current window and a baseline.
raglogs compare --since 30m --baseline 24h
raglogs compare --since 1h --baseline 7d
raglogs compare --since 2h --baseline 24h --service billing-worker
raglogs compare \
--window-a-from 2026-03-16T14:00:00Z --window-a-to 2026-03-16T14:30:00Z \
--window-b-from 2026-03-15T14:00:00Z --window-b-to 2026-03-15T14:30:00Z
raglogs compare --since 30m --baseline 24h --format json--since 30m --baseline 24h compares the last 30 minutes against the equivalent 30-minute window from 24 hours ago — the most useful form during an active incident.
| Flag | Description |
|---|---|
--since |
Incident window size, e.g. 30m, 1h |
--baseline |
Offset to baseline window, e.g. 24h, 7d |
--window-a-from/to |
Explicit start/end for window A (ISO 8601) |
--window-b-from/to |
Explicit start/end for window B (ISO 8601) |
--service |
Filter both windows to one service |
--env |
Filter both windows to one environment |
--format |
text or json |
Output sections
| Symbol | Meaning |
|---|---|
+ |
New cluster — present in A, absent in B |
- |
Disappeared — present in B, gone in A |
↑ |
Increased — in both, count grew by more than 50% |
↓ |
Decreased — in both, count shrank by more than 50% |
+⚡ |
New trigger — deploy or restart only seen in A |
-⚡ |
Dropped trigger — deploy or restart only seen in B |
Output
Incident comparison
Window A (now): 2026-03-16 15:17:42 UTC → 2026-03-16 15:47:42 UTC
Window B (baseline): 2026-03-15 15:17:42 UTC → 2026-03-15 15:47:42 UTC
New error clusters
+ Stripe signature verification failed for endpoint /webhooks/stripe 86 events
+ POST /api/checkout 500 Internal Server Error — upstream billing error 20 events
+ Webhook retries (24 distinct events, 24 total) 24 events
+ Webhook queue growing 13 events
Triggers in A not seen in B
+⚡ Deploy completed for billing-worker version v2.4.1 · deployment-controller
Individual webhook retry events (evt_XXXXXX) and queue-depth lines are deduplicated into single entries before diffing. No LLM required.
Lists the top log clusters in a time window ranked by importance score. Useful for exploration and understanding dominant event families without running a full explain.
raglogs clusters --since 1h
raglogs clusters --since 30m --service api
raglogs clusters --since 1h --top 20
raglogs clusters --since 1h --format jsonExample output
Top clusters — 2026-03-12 22:00:00 UTC → 2026-03-12 23:00:00 UTC
3 clusters found
# Count Chg Level Service(s) Message
1 184 184x error billing-worker Stripe signature verification failed for endpoint /webhooks/stripe
2 39 39x error api POST /api/checkout 500 Internal Server Error — upstream billing error
3 10 1.0x info deployment-ctrl Deploy completed for billing-worker version <token> ⚡
⚡ = likely trigger event Chg = change vs baseline
| Flag | Description |
|---|---|
--since |
Relative window |
--from / --to |
Explicit range |
--service |
Filter by service |
--env |
Filter by environment |
--top / -n |
Number of clusters to show (default: 15) |
--format |
text or json |
Answer a natural language question about your logs. Retrieval is semantic-first when embeddings exist and a provider is enabled, then keyword search, then a window-level error/warn fallback.
raglogs ask "why did login fail?"
raglogs ask "what changed before latency increased?" --since 2h
raglogs ask "what happened in billing?" --since 1h
raglogs ask "why are checkouts failing?" --format json
raglogs ask "why did login fail?" --ingestion-job <uuid>
raglogs ask "why did login fail?" --all-ingestionsLike explain / timeline / compare, ask defaults to the latest completed ingestion. Pass --ingestion-job to target a specific job, or --all-ingestions to search every ingested log.
| Flag | Description |
|---|---|
--since |
Relative window: 30m, 1h, 24h |
--service |
Filter to one service |
--format |
text or json |
--ingestion-job |
Scope to a specific ingestion job UUID |
--all-ingestions |
Search all historical ingestions, not just the latest |
How retrieval works
- Semantic — if
EMBEDDINGS_PROVIDERisopenaiorlocaland log lines were ingested with--with-embeddings, the question is embedded and nearest neighbors are fetched from pgvector (ASK_SEMANTIC_TOP_K,ASK_SEMANTIC_MIN_SIMILARITY). Paraphrases that keyword search would miss (e.g. "why are payments being declined?" vs "Stripe signature verification failed") can still match. - Keyword — if embeddings are disabled, the provider errors, or semantic search returns nothing above the similarity threshold,
askmatches tokens againstnormalized_messageas before. - Fallback — if keyword search is also empty, the top error/warn lines in the window are used.
Hits are still grouped with cluster_logs so answers stay grounded in counts, first_seen, and last_seen. JSON output includes retrieval_mode (semantic | keyword | fallback). Default EMBEDDINGS_PROVIDER=disabled keeps ask fully keyword-based — no API key required.
Populate vectors at ingest time:
raglogs ingest ./logs --with-embeddingsVectors are stored in log_embeddings (1536-d). If EMBEDDINGS_DIMENSIONS is not 1536, persist is skipped so the schema stays valid. Provider failures during ingest are skipped; the log lines still land.
Example output
Most likely cause related to 'why did the webhook fail?':
Stripe signature verification failed for endpoint /webhooks/stripe
In service: billing-worker
Evidence:
- 184 events: 'Stripe signature verification failed...' in billing-worker
- 39 events: 'POST /api/checkout 500...' in api
Total matching log events: 184
Shows database connectivity, log counts, and provider status.
raglogs statusDatabase: connected
Log entries: 464
Sources: 1
Ingestion jobs: 1
LLM provider: disabled
LLM model: gpt-4.1-mini
Embeddings: disabled
Inspect the current effective configuration.
raglogs config # show all
raglogs config llm_providerExpire raw log rows (and cascaded log-line embeddings / cluster membership) while keeping cluster summaries and cluster_embeddings so POST /v1/query/similar still works. After RETENTION_SUMMARY those summaries expire too. Per-scope TTLs override env defaults via the scope_retention table; missing override → RETENTION_RAW / RETENTION_SUMMARY. 0, empty, or off skips that tier.
The background worker (raglogs worker) also enqueues a purge job on idle poll about every PURGE_INTERVAL_SECONDS (default 3600). Purge uses SELECT FOR UPDATE SKIP LOCKED like ingest and deletes in created_at-ordered chunks (time-in-store) so ingest is not starved. Raw expiry uses log_entries.created_at, not the event timestamp, so historical dumps are not wiped on ingest.
raglogs purge # every scope with data
raglogs purge --scope default
raglogs purge --dry-run # count onlyMint, list, revoke, and set per-key query defaults for HTTP API keys. The API bearer token is printed once at create time and is never stored or logged — only an argon2 hash and a short prefix are kept. A separate webhook signing secret (whsec_…) is also printed once; it is stored server-side so ingest completion callbacks can be HMAC-signed. It is not the bearer token. raglogs keys list shows whsec_**** when a signing secret exists (legacy keys minted before this column fall back to WEBHOOK_SECRET).
raglogs keys create --role query --scope default --name "ci"
raglogs keys create --role query --scope incident:INC-9 --allow-scope-override --name "ci-override"
raglogs keys create --role query --max-clusters 5 --baseline-window 12h
raglogs keys set-defaults <key-uuid> --max-clusters 8 --max-evidence-items 4 --llm-provider ollama
raglogs keys set-defaults <key-uuid> --clear
raglogs keys list
raglogs keys revoke <key-uuid>| Flag | Description |
|---|---|
--role |
ingest, query, or admin (default query) |
--scope |
Pin the key to this isolation scope (enforced on every service read/write). Default default. Convention: incident:<id>, service:<name>, env:<name>. |
--allow-scope-override |
Allow the caller to pass a request scope other than the key's pin. Pinned by default. |
--name |
Optional label |
--max-clusters |
Per-key default max_clusters (1–100) stored on api_keys.config_json |
--max-evidence-items |
Per-key default max_evidence_items (1–50) |
--baseline-window |
Per-key default baseline duration (e.g. 24h) |
--llm-provider |
Per-key default openai / ollama / claude / disabled |
--llm-enabled / --no-llm-enabled |
Per-key default for whether the LLM is used |
raglogs keys set-defaults merges flags into the key's config_json. --clear removes all per-key query defaults. Requires a migrated database (raglogs init). See HTTP API authentication and per-request overrides.
All settings are read from .env, environment variables, or CLI flags. Priority: CLI > env var > .env file > defaults.
| Variable | Default | Description |
|---|---|---|
DB_URL |
postgresql+psycopg://postgres:postgres@localhost:5432/raglogs |
PostgreSQL connection URL |
LLM_PROVIDER |
disabled |
disabled, openai, ollama, claude |
LLM_MODEL |
gpt-4.1-mini |
LLM model name. Use claude-haiku-4-5 when LLM_PROVIDER=claude |
OPENAI_API_KEY |
(empty) | API key for OpenAI or compatible endpoint |
OPENAI_BASE_URL |
https://api.openai.com/v1 |
Base URL for OpenAI-compatible API |
OLLAMA_BASE_URL |
http://localhost:11434 |
Ollama server URL |
ANTHROPIC_API_KEY |
(empty) | API key for Claude (LLM_PROVIDER=claude). Empty falls back to noop |
ANTHROPIC_BASE_URL |
https://api.anthropic.com |
Anthropic Messages API host |
EMBEDDINGS_PROVIDER |
disabled |
disabled, openai, local. Cluster merge, semantic ask, and /similar ANN skip when disabled |
EMBEDDINGS_MODEL |
text-embedding-3-small |
Embeddings model name |
EMBEDDINGS_DIMENSIONS |
1536 |
Vector size passed to the OpenAI embeddings API. Persist/ask skip unless this is 1536 (stored column width) |
CLUSTER_MERGE_SIMILARITY_THRESHOLD |
0.92 |
Cosine similarity at or above which fingerprint clusters merge. High on purpose so distinct errors stay separate |
CLUSTER_MERGE_MIN_COUNT |
1 |
Minimum count for a cluster to participate in a merge |
ASK_SEMANTIC_TOP_K |
100 |
Max log lines returned by semantic ask |
ASK_SEMANTIC_MIN_SIMILARITY |
0.75 |
Minimum cosine similarity for a semantic ask hit. Looser than cluster-merge because questions paraphrase |
SIMILAR_SEMANTIC_MIN_SIMILARITY |
0.80 |
Minimum cosine similarity for POST /v1/query/similar. Falls back to fingerprint equality when embeddings are down |
DEFAULT_BASELINE_WINDOW |
24h |
How far back to compare for baseline |
MAX_CLUSTERS_FOR_EXPLAIN |
10 |
Max clusters sent to the explain pipeline |
MAX_EVIDENCE_ITEMS |
8 |
Max evidence lines in output |
ADAPTER_CLOUDWATCH_REGION |
us-east-1 |
AWS region for --adapter cloudwatch |
DATADOG_API_KEY |
(empty) | Datadog API key (logs_read_data) |
DATADOG_APP_KEY |
(empty) | Datadog application key |
DATADOG_SITE |
datadoghq.com |
Datadog site (us3.datadoghq.com, datadoghq.eu, …) |
DATADOG_PAGE_SIZE |
1000 |
Logs per Datadog page (API max 1000) |
DATADOG_MAX_ROWS |
10000 |
Max events pulled in one Datadog ingest run |
AUTH_ENABLED |
false |
Require Authorization: Bearer on the HTTP API (except /health and /metrics). Keep false for local demo; set true in production/Docker |
AUTH_MODE |
api_key |
api_key, oidc, or both |
OIDC_ISSUER |
(empty) | JWT issuer when AUTH_MODE is oidc or both |
OIDC_AUDIENCE |
(empty) | Optional JWT audience |
OIDC_JWKS_URL |
(empty) | Optional JWKS URL; default is {issuer}/.well-known/openid-configuration then {issuer}/.well-known/jwks.json |
API_BIND_HOST |
127.0.0.1 |
Host the startup guard treats as the bind address. make api sets this to 0.0.0.0 to match uvicorn |
AUTH_REFUSE_INSECURE_BIND |
false |
If true, refuse to start when auth is off and the bind host is not loopback; if false, log a warning only |
RATELIMIT_ENABLED |
true |
Token-bucket rate limiting on ingest writes and query routes. In-memory per process (not shared across workers) |
RATELIMIT_INGEST_RPS |
100 |
Steady-state tokens/sec for POST /v1/ingestions*. 0 = unlimited |
RATELIMIT_QUERY_RPS |
100 |
Steady-state tokens/sec for /v1/query*. 0 = unlimited |
RATELIMIT_BURST |
100 |
Bucket size (max tokens) per API key (or anonymous when auth is off) |
RATELIMIT_RETRY_AFTER_SECONDS |
1 |
Retry-After value on 429 RATE_LIMITED |
INGEST_QUEUE_MAX |
100 |
Pending worker-job ceiling; over this, ingest returns 429 INGEST_QUEUE_FULL |
INGEST_RETRY_AFTER_SECONDS |
5 |
Retry-After value on 429 INGEST_QUEUE_FULL |
LLM_MAX_CONCURRENCY |
4 |
Max in-flight LLM provider calls process-wide. 0 = unlimited. Noop does not wait |
LLM_TIMEOUT |
30 |
Per-attempt HTTP timeout in seconds for OpenAI/Ollama calls |
LLM_MAX_RETRIES |
2 |
Extra attempts after the first (3 total) with jittered exponential backoff |
LLM_MAX_TOKENS |
600 |
Completion cap (max_tokens / Ollama num_predict) |
LLM_MAX_INPUT_TOKENS |
0 |
Estimated input-token budget (chars/4). 0 derives from LLM_MAX_TOKENS. Over budget: trim evidence (respecting MAX_EVIDENCE_ITEMS) or fall back |
LLM_BREAKER_THRESHOLD |
5 |
Consecutive LLM failures before the process-local breaker opens |
LLM_BREAKER_COOLDOWN_SECONDS |
60 |
Seconds the breaker stays open before a half-open probe |
WEBHOOK_SECRET |
(empty) | Fallback HMAC secret for ingest completion callbacks when auth is off or the API key has no per-key whsec_ |
WEBHOOK_MAX_RETRIES |
5 |
Extra webhook POST attempts after the first (6 POSTs by default) on 5xx / 429 / connect errors |
WEBHOOK_TIMEOUT |
10 |
Per-attempt HTTP timeout in seconds for completion callbacks |
INGEST_IDEMPOTENCY_TTL_SECONDS |
86400 |
How long Idempotency-Key on POST /v1/ingestions is remembered (batch enqueue and tail create) |
RETENTION_RAW |
30d |
How long to keep raw log_entries measured by created_at (time-in-store; cascaded log_embeddings / cluster_members). 0 / empty / off = never purge. Per-scope override: scope_retention.raw_interval |
RETENTION_SUMMARY |
180d |
How long to keep cluster summaries + cluster_embeddings after which similar-incident recall for that scope expires. Same 0 / off disable |
PURGE_INTERVAL_SECONDS |
3600 |
Idle worker poll interval between automatic purge jobs. 0 disables scheduled purge (raglogs purge still works) |
PURGE_CHUNK_SIZE |
1000 |
Max rows deleted per table per scope per purge job (worker does one chunk; CLI drains) |
LOG_FORMAT |
json |
Structured log renderer: json or console. API uses this; CLI switches to console on a TTY |
OTEL_SDK_DISABLED |
false |
Skip the OpenTelemetry SDK. Request ids are still generated |
OTEL_EXPORTER_OTLP_ENDPOINT |
(empty) | Optional OTLP HTTP traces endpoint. Empty = no exporter (no collector required) |
OTEL_SERVICE_NAME |
raglogs |
Resource service.name on exported spans |
raglogs is fully useful without any LLM. The --no-llm flag (or LLM_PROVIDER=disabled) activates deterministic template-based summaries.
When an LLM is configured, it receives only a small curated evidence packet — not raw logs. The prompt enforces fixed output structure, prohibits fabrication, and requires explicit uncertainty statements when evidence is insufficient. In-flight provider calls are capped by LLM_MAX_CONCURRENCY (CLI and API share the process semaphore; the noop provider does not block).
Every provider call has a timeout (LLM_TIMEOUT) and bounded jittered retries (LLM_MAX_RETRIES). On timeout, HTTP error, exhausted retries, or an over-budget evidence payload, explain/ask fall back to the same deterministic templates and set llm.fell_back=true — the request still succeeds. After LLM_BREAKER_THRESHOLD consecutive failures a process-local circuit breaker opens for LLM_BREAKER_COOLDOWN_SECONDS; while open, raglogs skips the provider entirely and serves templates. GET /health exposes llm_breaker: {state, consecutive_failures, cooldown_remaining_seconds} (closed / open / half_open). An open breaker marks status as degraded but still returns HTTP 200 so probes do not fail. Fallback never invents: it only renders the curated evidence packet.
LLM_PROVIDER=openai
LLM_MODEL=gpt-4.1-mini
OPENAI_API_KEY=sk-...LLM_PROVIDER=ollama
LLM_MODEL=llama3
OLLAMA_BASE_URL=http://localhost:11434LLM_PROVIDER=claude
LLM_MODEL=claude-haiku-4-5
ANTHROPIC_API_KEY=sk-ant-...Uses the Anthropic Messages API (POST /v1/messages) via raw httpx. An empty ANTHROPIC_API_KEY falls back to the deterministic template provider. Override ANTHROPIC_BASE_URL only for a proxy. The OpenAI default model (gpt-4.1-mini) is unchanged when LLM_PROVIDER=openai.
LLM_PROVIDER=openai
OPENAI_BASE_URL=http://localhost:1234/v1
OPENAI_API_KEY=not-requiredraglogs accepts structured JSON logs and resolves common field aliases automatically.
{"timestamp": "2026-03-12T22:01:10Z", "level": "error", "service": "billing-worker", "message": "Stripe signature verification failed"}
{"ts": "2026-03-12T22:01:10Z", "severity": "ERROR", "app": "api", "msg": "checkout returned 500"}
{"@timestamp": "2026-03-12T22:01:10Z", "log_level": "WARN", "logger": "worker", "log": "Queue depth exceeded threshold"}Supported field aliases:
| Field | Accepted names |
|---|---|
| Timestamp | timestamp, ts, time, @timestamp, datetime |
| Message | message, msg, log, text, body |
| Level | level, severity, log_level, loglevel, lvl |
| Service | service, app, logger, component, application |
| Environment | environment, env, deployment, stage |
| Trace ID | trace_id, traceId, trace |
| Request ID | request_id, requestId, req_id, correlation_id |
| Host | host, hostname, server, instance, pod |
2026-03-12T22:01:10Z ERROR billing-worker Stripe signature verification failed
[2026-03-12T22:01:10Z] [WARN] High memory usage detected on worker-3
raglogs uses regex heuristics to extract timestamp, level, service, and message from common plain-text formats. If service is not found in the line, it can be provided with --service or inferred from the filename.
By default (--format auto), raglogs samples the first non-empty line of each file to detect JSON vs plain text. Override with --format json or --format text.
Log Files
│
▼
File Adapter
(discover files, detect format, read lines)
│
▼
Parser
(JSON / text, field aliases, timestamp normalization)
│
▼
Normalization
(replace: UUIDs, IPs, emails, tokens, numeric IDs, paths, timestamps)
(preserve: endpoint names, status codes, exception names, service names)
│
▼
Fingerprinting
(SHA-256 of normalized message → stable 16-char cluster key)
│
▼
PostgreSQL + pgvector
(indexed on timestamp, service, environment, fingerprint)
│
▼
Clustering
(group by fingerprint → count, services, levels, first/last seen)
│
▼
Semantic merge (optional)
(embed cluster representatives; merge pairs with cosine ≥ threshold)
│
▼
Baseline Comparison
(compare current window to prior window, compute change ratio)
│
▼
Importance Ranking
(severity weight + log(count) + log(change ratio) + service spread + trigger correlation)
│
▼
Evidence Assembly
(trigger detection, timing correlation, primary + secondary cluster selection)
│
▼
LLM (optional) or Deterministic Templates
│
▼
Incident Summary · Timeline · Diff
Normalization is the most important step for clustering quality. It strips dynamic values from log messages so semantically identical events get the same fingerprint regardless of which specific user ID, request ID, or IP address was involved.
| Raw message | Normalized |
|---|---|
User 12345 failed login from 192.168.1.1 |
User <id> failed login from <ip> |
Request req_abc123 timed out after 3000ms |
Request <*>=<*> timed out after <duration> |
Processing job 550e8400-e29b-41d4-a716-446655440000 |
Processing job <uuid> |
GET /api/users?page=2&limit=50 200 OK |
GET /api/users?<params> 200 OK |
Things deliberately not normalized: endpoint paths, HTTP status codes, exception class names, service names, operation names.
Fingerprinting can still split one incident across multiple clusters when wording differs enough that normalized templates diverge (for example two Stripe error strings that mean the same failure). After fingerprint grouping, raglogs can embed each cluster's representative message and merge near-duplicates.
- Disabled (default).
EMBEDDINGS_PROVIDER=disabledskips the merge pass entirely. Clustering is fingerprint-only and deterministic — the same logs always produce the same clusters. - Enabled. With
openaiorlocal, representatives are embedded at analysis time (in memory; not written to pgvector). Pairs with cosine similarity ≥CLUSTER_MERGE_SIMILARITY_THRESHOLD(default 0.92) are merged via connected components. Mergedcountis the sum of member counts; services and levels are summed;first_seenis the earliest timestamp andlast_seenthe latest; importance is recomputed. The canonical fingerprint is the member with the highest importance score.ClusterRun.algorithmisfingerprint+semanticwhen embeddings were used, even if no pair crossed the threshold. - Fail open. If the embeddings backend is missing, raises, or returns unusable vectors, clustering continues with the fingerprint-only set. The same fail-open applies when upserting
cluster_embeddingsfor similar-incident search: a provider outage never fails the cluster run. - Cluster template persist. After clustering, raglogs upserts each cluster's representative template into
cluster_embeddingskeyed by(scope, fingerprint)when the embeddings provider is available. Similar-incident search queries those rows (not rawlog_embeddings). Skip happens automatically whenEMBEDDINGS_PROVIDER=disabled. - Ask vs merge vs similar. Semantic
askuses the storedlog_embeddingstable (populated byraglogs ingest --with-embeddings) andASK_SEMANTIC_MIN_SIMILARITY(default 0.75). Cluster merge still uses its own in-memory pass and threshold. Similar-incident search (POST /v1/query/similar) uses thecluster_embeddingstable (upserted at analysis time when an embeddings provider is available) andSIMILAR_SEMANTIC_MIN_SIMILARITY(default 0.80). Compare still applies its own heuristic collapse for webhook retries / queue growth after clustering.
Local embeddings require the optional extra: pip install 'raglogs[local-embeddings]' (sentence-transformers). If that import fails, merge is skipped.
For every cluster in the incident window, raglogs computes a change ratio against the baseline window:
change_ratio = (current_count + 1) / (baseline_count + 1)
A cluster that fires 200 times and usually fires 180 is probably normal. A cluster that fires 5 times but has never appeared before has a change ratio of 6 and ranks much higher. The smoothing term prevents divide-by-zero explosions on new clusters.
Default baseline window is the 24 hours before the incident window. Configurable with --baseline-window or DEFAULT_BASELINE_WINDOW.
raglogs scans for log messages matching known trigger patterns in the minutes before the primary error cluster begins. Matched patterns include:
- Deploy started / completed
- Application or service restart
- Pod restart / eviction
- Configuration reloaded
- Migration started / completed
- Queue saturation
- Circuit breaker open
- Webhook secret or config mismatch
- Auth token expiration bursts
A trigger candidate is promoted to "likely trigger" when it precedes the primary error spike and shares the same or an adjacent service.
raglogs timeline assembles events into three causal buckets without any ML or LLM:
- Pre-error — trigger candidates (deploys, startups) sorted by timestamp
- Error — the primary cluster at its first occurrence
- Post-error — secondary clusters (effects, symptoms) sorted by first occurrence
Secondary clusters are classified by message content: queue/backlog growth becomes symptom, 500 errors and latency spikes become effect. Repeated webhook retry events (individual evt_XXXXXX lines) are deduplicated into a single count. Effects that appear to have started before the primary error — due to data noise — are floored to the primary's first occurrence to preserve causal ordering.
raglogs compare runs clustering independently on both windows, then diffs the resulting fingerprint sets. Before diffing, each cluster set is collapsed: all evt_XXXXXX retry clusters merge into a single entry, and all queue-depth lines merge into one. The collapsed maps are then diffed by fingerprint, with counts compared to determine direction (new, disappeared, increased, decreased). Trigger candidates are normalized by message prefix to handle version strings, so v2.4.1 and v2.3.9 both resolve as "deploy" without creating spurious diffs.
Confidence is derived from measurable signals, not from LLM output:
- Cluster volume (more events → higher confidence)
- Baseline change ratio (larger spike → higher confidence)
- Presence of a trigger candidate
- Secondary cluster corroboration
- Multi-service spread
- Total log volume in window
Possible values: low, medium, medium-high, high.
raglogs exposes a FastAPI server for integrations and future tooling.
uvicorn src.api.app:app --host 0.0.0.0 --port 8000 --reload
# or
make apimake api still binds 0.0.0.0. With the default AUTH_ENABLED=false that logs a loud startup warning. Bind loopback instead with make api API_BIND_HOST=127.0.0.1, or enable auth (below).
Auth is off by default so local demo and existing clients keep working. Set AUTH_ENABLED=true before exposing the API on a network (including Docker Compose on 0.0.0.0).
export AUTH_ENABLED=true
raglogs keys create --role admin --name "local"
# copy the rlk_… API key and the whsec_… webhook secret from the panels — each is shown only once
curl -X POST http://localhost:8000/v1/query/explain \
-H "Authorization: Bearer rlk_…" \
-H "Content-Type: application/json" \
-d '{"since": "30m", "no_llm": true}'| Role | Allowed |
|---|---|
ingest |
POST /v1/ingestions, POST /v1/ingestions/lines, tail lifecycle pause / resume / stop (and the deprecated /ingestions aliases) |
query |
GET /v1/ingestions*, POST /v1/query/*, web UI (GET /, /static), OpenAPI (/docs) |
admin |
everything, including GET /v1/config |
GET /health and GET /metrics are always unauthenticated. /docs is not exempt. /metrics returns Prometheus text (text/plain) and is not rate-limited.
Missing or invalid Authorization: Bearer returns 401:
{"error_code": "AUTH_UNAUTHORIZED", "message": "…"}A valid key with the wrong role returns 403:
{"error_code": "AUTH_FORBIDDEN", "message": "…"}Keys are stored argon2-hashed with a short indexed prefix. Each key is pinned to a scope (default default) and every service ingest/query is filtered by that scope — including baseline comparison — so one incident's logs cannot contaminate another. Mint with --allow-scope-override to let the caller pass a request scope. A service request with no resolvable scope returns 400:
{"error_code": "SCOPE_REQUIRED", "message": "…"}A pinned key that sends a different non-empty scope returns 403 SCOPE_MISMATCH. The CLI is scope-optional and defaults to default (raglogs ingest --scope incident:INC-9, raglogs explain --scope …). Each new key also gets a whsec_… webhook signing secret (shown once; keys list shows whsec_**** only).
POST /v1/query/similar cross-scope permissions. Similar-incident search can look across isolation scopes ("we saw this in INC-1188") when the caller is allowed to see those scopes. Matches from a scope the caller cannot see are never returned.
| Caller | Cross-scope similar |
|---|---|
admin keys |
Yes by default. Pass "cross_scope": false to pin to the resolved scope. |
query keys that are pinned |
Same-scope only. "cross_scope": true is ignored. |
query keys minted with --allow-scope-override |
Same-scope unless the body sets "cross_scope": true. |
AUTH_ENABLED=false (local CLI / demo) |
Cross-scope allowed by default. Pass "cross_scope": false to pin. |
Optional OIDC: set AUTH_MODE=oidc or both and OIDC_ISSUER. A JWT (three dotted segments) is validated via JWKS (iss, exp, and aud when OIDC_AUDIENCE is set). Role comes from claim raglogs_role or roles, defaulting to query. When AUTH_MODE=api_key, JWTs are rejected.
If auth is disabled and the process binds a non-loopback address (0.0.0.0, ::, a public IP), raglogs logs a warning. Set AUTH_REFUSE_INSECURE_BIND=true to refuse startup instead.
| Method | Endpoint | Description |
|---|---|---|
GET |
/health |
Service and DB health check (unversioned). Includes adapters, tail_jobs, llm: {provider, status}, and llm_breaker (closed / open / half_open). Open breaker → status: degraded, still HTTP 200. |
GET |
/metrics |
Prometheus scrape (unversioned, unauthenticated). Ingest/query latency, ingest line counts, cluster counts, LLM latency/fallback/tokens, breaker state, worker queue depth. |
POST |
/v1/ingestions |
Enqueue a batch ingest job (adapter: file, cloudwatch, datadog, loki, or k8s). Set "mode": "tail" for pull adapters to start a long-lived tail job. |
POST |
/v1/ingestions/lines |
Push NDJSON of raw or pre-parsed log lines (sync persist) |
POST |
/v1/ingestions/{id}:pause |
Pause a tail job |
POST |
/v1/ingestions/{id}:resume |
Resume a paused tail job |
POST |
/v1/ingestions/{id}:stop |
Stop a tail job (terminal; cannot resume) |
GET |
/v1/ingestions |
List recent completed ingestion jobs, newest first |
GET |
/v1/ingestions/{job_id} |
Fetch ingestion job detail |
GET |
/v1/ingestions/latest |
ID of the most recently completed ingestion job, if any |
POST |
/v1/query/explain |
Explain a time window |
POST |
/v1/query/ask |
Answer a natural language question |
POST |
/v1/query/clusters |
List top clusters |
POST |
/v1/query/timeline |
Reconstruct incident timeline for a window |
POST |
/v1/query/compare |
Diff two time windows (same semantics as raglogs compare) |
POST |
/v1/query/similar |
Prior incidents with nearby fingerprints ("we saw this in INC-1188") |
GET |
/v1/config |
Read effective configuration |
Push NDJSON. POST /v1/ingestions/lines accepts newline-delimited lines (Content-Type: application/x-ndjson, application/jsonl, or text/plain). Each line is a raw log string or a JSON object with at least message / raw / text (optional timestamp, service, level, host, env). Cap is INGEST_PUSH_MAX_LINES (default 5000); over the cap returns 400.
curl -X POST http://localhost:8000/v1/ingestions/lines \
-H "Content-Type: application/x-ndjson" \
--data-binary $'{"message":"timeout talking to payments","level":"error","service":"api"}\nplain syslog line\n'Tail jobs. POST /v1/ingestions with "mode": "tail" (adapters cloudwatch, datadog, or loki only) creates a long-lived job. The worker re-runs the adapter from the saved cursor about every TAIL_POLL_INTERVAL seconds (default 30). Pause, resume, or stop with:
curl -X POST http://localhost:8000/v1/ingestions \
-H "Content-Type: application/json" \
-d '{"adapter":"loki","params":{"query":"{app=\\"api\\"}"},"mode":"tail"}'
# → { "ingestion_job_id": "...", "mode": "tail", "status": "running" }
curl -X POST http://localhost:8000/v1/ingestions/$ID:pause
curl -X POST http://localhost:8000/v1/ingestions/$ID:resume
curl -X POST http://localhost:8000/v1/ingestions/$ID:stopstop is terminal. After TAIL_ERROR_THRESHOLD consecutive poll failures (default 5) a tail job auto-pauses; /health reports tail_jobs.running and tail_jobs.paused.
Backpressure and rate limiting. Two independent 429s:
- Queue depth. When pending worker jobs ≥
INGEST_QUEUE_MAX(default 100),POST /v1/ingestionsandPOST /v1/ingestions/linesreturn 429 withRetry-After(INGEST_RETRY_AFTER_SECONDS, default 5) and body{"error_code":"INGEST_QUEUE_FULL","message":"..."}. Tail ticks skip the same ceiling. - API token bucket.
POST /v1/ingestions*(writes) and/v1/query*(plus unversioned aliases) are limited per API key (request.state.auth_principal.key_id, or a singleanonymousbucket whenAUTH_ENABLED=false). Exceeding the bucket returns 429 withRetry-After(RATELIMIT_RETRY_AFTER_SECONDS, default 1) and body{"error_code":"RATE_LIMITED","message":"..."}. Defaults (RATELIMIT_INGEST_RPS/RATELIMIT_QUERY_RPS/RATELIMIT_BURST= 100) are high enough for local demo and tests;0rps means unlimited for that category./health,/metrics,/docs, static UI, and/configare not limited. Buckets are in-memory per process.
LLM calls are separately capped by LLM_MAX_CONCURRENCY (default 4) so a burst of explain cannot fan out unbounded provider requests. Timeouts, retries, automatic template fallback (llm.fell_back), and the process-local circuit breaker are described under LLM integration.
Observability. Every response echoes X-Request-Id (honors incoming X-Request-Id / X-Request-ID, otherwise a UUID) and W3C traceparent plus X-Trace-Id. Structured JSON logs (structlog) include request_id and resolved scope via contextvars; secrets and bearer tokens are never logged. GET /metrics names:
| Metric | Type | Meaning |
|---|---|---|
raglogs_ingest_duration_seconds |
histogram | Pipeline ingest wall time |
raglogs_ingest_lines_total |
counter (result) |
Lines inserted / deduped / error |
raglogs_ingest_request_duration_seconds |
histogram | HTTP ingest write latency |
raglogs_cluster_count |
histogram | Clusters produced per run |
raglogs_query_request_duration_seconds |
histogram (endpoint) |
HTTP query latency |
raglogs_llm_request_duration_seconds |
histogram | LLM provider call latency |
raglogs_llm_fallback_total |
counter | G10 template fallbacks |
raglogs_llm_estimated_tokens_total |
counter | Estimated input tokens (UTF-8 chars/4, not USD) |
raglogs_llm_breaker_state |
gauge | 0 closed, 1 half_open, 2 open |
raglogs_worker_queue_depth |
gauge | Pending worker jobs (omitted until a successful scrape; left stale if DB fails) |
raglogs_purge_rows_total |
counter (kind) |
Rows reclaimed by retention purge: raw, summary, or embedding |
OpenTelemetry spans cover ingest → cluster → explain (and the HTTP request). The default exporter is none; set OTEL_EXPORTER_OTLP_ENDPOINT to export, or OTEL_SDK_DISABLED=true to skip the SDK. Trace ids stay on response headers so /v1/query/* JSON (schema_version 1.0) is unchanged.
Idempotency-Key. POST /v1/ingestions (batch enqueue and tail create; also the deprecated /ingestions alias) honors an Idempotency-Key header (max 256 characters). A repeat in the same isolation scope within INGEST_IDEMPOTENCY_TTL_SECONDS (default 86400) returns the original 202 job — the same worker_job_id for batch, the same ingestion_job_id for tail — instead of starting a new one. Reusing another scope's key returns 409 IDEMPOTENCY_SCOPE_CONFLICT. Empty keys return 400. GET routes ignore the header. POST /v1/ingestions/lines does not use the header; duplicate push/tail lines are handled by content dedup instead.
curl -X POST http://localhost:8000/v1/ingestions \
-H "Content-Type: application/json" \
-H "Idempotency-Key: incident-123-retry" \
-d '{"paths":["/var/log/app"]}'Content dedup. Every persist path (ingest_files, ingest_from_source including tail ticks, and push /lines) stores original_line_hash (SHA-256 of the raw line, distinct from the normalized fingerprint) and upserts on (scope, source_ref, original_line_hash, timestamp). Re-reading the same physical lines is a no-op, so cluster counts stay stable across overlapping windows and tail/push retries. Missing source_ref is stored as "" so uniqueness works (Postgres NULLs are distinct). scope defaults to "default" (CLI) or is resolved from the API key / request (service). Queries, ingest lists, and baseline comparison are filtered by the same scope. Duplicate lines are skipped, not errors.
Completion callbacks. Optional callback_url on POST /v1/ingestions (http or https only; file: and empty hosts are rejected). When a batch worker job reaches a terminal state (done / failed), raglogs POSTs an HMAC-SHA256-signed JSON body to that URL. Delivery is fail-open: retries with jittered exponential backoff (WEBHOOK_MAX_RETRIES, default 5 extra attempts) on 5xx, 429, and connect errors; 4xx other than 429 are not retried. Failures are logged and do not change ingest status — poll GET /v1/ingestions/jobs/{worker_job_id} still works.
job_id in the payload is the ingestion_job_id when ingest created a row; if the worker failed before that, it is the worker_job_id. scope is the resolved isolation scope (API key pin, request override when allowed, or "default" when auth is off). counts.clusters is 0 (clustering is not part of ingest). Worker done maps to "succeeded" (or "partial" when error_count > 0); failed maps to "failed".
curl -X POST http://localhost:8000/v1/ingestions \
-H "Content-Type: application/json" \
-d '{"paths":["/var/log/app"],"callback_url":"https://example.com/hooks/raglogs"}'Example body:
{
"job_id": "a1b2c3d4-…",
"status": "succeeded",
"scope": "default",
"counts": { "lines": 48213, "clusters": 0 },
"partial": false
}The signature is not inside the JSON. It is sent as:
X-Raglogs-Signature: sha256=<hex>
Verify by HMAC-SHA256 of the raw request body with the webhook secret, then compare to the header (constant-time). Use the per-key whsec_… from raglogs keys create when the ingest request was authenticated with that API key; otherwise WEBHOOK_SECRET. Do not HMAC the rlk_… bearer token — it is not stored.
Python:
import hmac, hashlib
def verify(secret: str, body: bytes, header: str) -> bool:
digest = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
expected = "sha256=" + digest
return hmac.compare_digest(expected, header)Tail jobs and POST /v1/ingestions/lines (sync push) do not fire callbacks in this release — tail jobs are long-lived (poll or :stop); push completes in the HTTP response.
Overlapping tail poll windows can double-count the same physical lines until content dedup (G6) lands. Prefer a single tail job per source and avoid also batch-ingesting the same window.
Unversioned /ingestions, /query/*, and /config remain as deprecated aliases for one release. They behave the same as the /v1 paths and send Deprecation: true plus a Link: </v1/...>; rel="successor-version" header. /health, /metrics, the web UI (/), and /static stay unversioned.
Compatibility policy. Additive changes stay in v1. Breaking path or method removals require v2. /v1/query/* JSON bodies are schema_version 1.0: structured fields plus an llm provenance block (used, provider, model, fell_back). Prose is rendered_text (and format: text still adds a text alias; format: markdown still adds markdown). Additive fields may appear in 1.x; a breaking body change requires schema_version 2.0 / v2.
OpenAPI and clients. Export the spec with make openapi (clients/openapi.json). CI uploads that file as a workflow artifact and attaches it to GitHub Releases on tags. A thin typed Python client ships as src.clients.v1.RaglogsClient (targets /v1). make client-go runs oapi-codegen into clients/go/ when the binary is installed; otherwise it prints the install command and exits 0. See clients/README.md.
Example
curl -X POST http://localhost:8000/v1/query/explain \
-H "Content-Type: application/json" \
-d '{"since": "30m", "no_llm": true}'{
"schema_version": "1.0",
"scope": "default",
"window": {"from": "2026-03-12T22:00:00+00:00", "to": "2026-03-12T22:30:00+00:00"},
"confidence": {"label": "medium-high", "score": 0.72},
"summary": "Stripe signature verification failed for endpoint /webhooks/stripe",
"trigger": {"detected": false, "type": null, "service": null, "at": null, "correlation": null},
"primary_cluster": {
"fingerprint": "a1b2c3d4",
"template": "Stripe signature verification failed for endpoint /webhooks/stripe",
"count": 184,
"baseline_count": 0,
"change_ratio": 185.0,
"services": ["billing-worker"],
"levels": ["error"]
},
"evidence": [
{"kind": "log", "detail": "184 similar errors in billing-worker"}
],
"llm": {"used": false, "provider": "disabled", "model": "gpt-4.1-mini", "fell_back": false},
"rendered_text": "Incident summary\n\nWindow: ...",
"cached": false,
"total_logs": 464
}Explain — POST /v1/query/explain accepts the same window filters as the CLI. Optional "format": "markdown" adds a paste-ready markdown incident report field alongside the JSON payload (same shape as raglogs explain --format markdown).
POST /v1/query/* bodies accept optional tunables that override server defaults for that call only:
{
"since": "30m",
"baseline_window": "24h",
"max_clusters": 10,
"max_evidence_items": 8,
"llm": { "provider": "openai", "enabled": true }
}| Field | Bounds | Server default |
|---|---|---|
baseline_window |
duration parsed like CLI (30m, 24h, 7d) |
DEFAULT_BASELINE_WINDOW (24h) |
max_clusters |
1–100 | MAX_CLUSTERS_FOR_EXPLAIN (10) |
max_evidence_items |
1–50 | MAX_EVIDENCE_ITEMS (8) |
llm.provider |
openai / ollama / claude / disabled |
LLM_PROVIDER |
llm.enabled |
bool; false acts like no_llm |
inferred from LLM_PROVIDER |
Precedence: request field > per-key default (api_keys.config_json) > server env default. Omitted fields fall through. When AUTH_ENABLED=false the per-key layer is skipped. llm.provider does not persist globally; openai without OPENAI_API_KEY and claude without ANTHROPIC_API_KEY still use the noop provider. The explain cache key includes the resolved overrides so different max_clusters values do not share an entry.
Invalid values return 400:
{"error_code": "INVALID_OVERRIDE", "message": "max_clusters must be between 1 and 100", "field": "max_clusters", "min": 1, "max": 100}Applies to explain, timeline, compare, clusters, ask, and similar (similar uses max_clusters when clustering; top remains the match count). Clusters still accepts top as an alias for max_clusters when max_clusters is omitted.
Timeline — POST /v1/query/timeline accepts the same window filters as the CLI (since or from_time/to_time, optional service, env, all_ingestions, ingestion_job_id). Set "format": "text" to include plain-text rendered_text (and a text alias) alongside events. Timeline is rules-only (llm.used is always false).
curl -X POST http://localhost:8000/v1/query/timeline \
-H "Content-Type: application/json" \
-d '{"since": "2h", "format": "json"}'Compare — POST /v1/query/compare matches raglogs compare: either "since" + "baseline" (durations, window A ends at request time) or explicit window_a_from / window_a_to / window_b_from / window_b_to. Optional "format": "text" adds rendered_text (and a text alias). Cluster diffs include a marker (+ / - / ↑ / ↓; triggers use +⚡ / -⚡). Compare is rules-only.
Similar — POST /v1/query/similar finds prior incidents whose cluster templates are near the current window's primary fingerprint(s). Pass since / from_time / to_time or ingestion_job_id to cluster the current incident, or skip clustering with "fingerprint" / "fingerprints". Optional "top" (default 10) and "cross_scope" (see permissions above). Response retrieval_mode is "semantic" when pgvector ANN over cluster_embeddings hits, otherwise "fingerprint" (exact fingerprint equality, never HTTP 500 when embeddings are down). Rules-only (llm.used is always false).
curl -X POST http://localhost:8000/v1/query/similar \
-H "Content-Type: application/json" \
-d '{"since": "1h", "cross_scope": true}'Published JSON Schema files live in clients/jsonschema/ (explain.v1.json, timeline.v1.json, compare.v1.json, ask.v1.json, clusters.v1.json, similar.v1.json). Export with make jsonschema.
curl -X POST http://localhost:8000/v1/query/compare \
-H "Content-Type: application/json" \
-d '{"since": "30m", "baseline": "24h"}'A minimal browser dashboard for the same explain / timeline / compare / ask flows the CLI exposes — no separate build step, served by the API itself.
make web
# starts Postgres, runs migrations, seeds a fresh sample incident, and
# serves the UI at http://localhost:8000/ — open it and there's already
# something to explain.make web always reseeds a fresh sample incident (each run adds a new
ingestion — same as make demo). For repeat runs where you don't want
that: make web-serve starts Postgres, migrates, and serves without
reseeding. Already have Postgres running and migrated? make api starts
just the server.
Pick a time window — relative presets or a duration like 2h (the default), or
switch to absolute UTC from/to datetimes — then switch between the
Explain, Timeline, Compare, and Ask tabs. The ingestion
dropdown in the top bar lists your 25 most recent completed ingestions (via
GET /v1/ingestions) and defaults to the latest one, matching the CLI; pick a
different ingestion or "All ingestions" to change what a query is scoped to.
The UI is server-rendered (Jinja2 + vanilla JS/CSS, no CORS, no node/npm) and
calls the same /v1/query/* JSON endpoints listed above.
With default AUTH_ENABLED=false the UI is open — fine for local dev. When
auth is on, load the UI with a query or admin bearer token (the browser
does not attach Authorization on its own; put raglogs behind a proxy that
injects the header, or keep auth off on loopback). /health stays public.
# Install everything
pip install -r requirements.txt && pip install -e .
# Unit tests (no DB needed)
make test-unit
# Integration tests (requires running Postgres)
make test-int
# API with hot reload
make api
# Lint / format
make lint
make format
# Export OpenAPI spec (clients/openapi.json)
make openapi
# Optional generated clients (see clients/README.md)
make client-go
make client-python
# Full clean
make cleanProject structure
raglogs/
├── src/
│ ├── adapters/ Log source adapters (file, cloudwatch, datadog, loki, k8s)
│ ├── api/routes/ FastAPI route handlers
│ ├── api/auth/ API keys, roles, OIDC, bind-host guard
│ ├── cli/commands/ Typer CLI commands
│ ├── clients/ Thin typed HTTP client targeting /v1
│ ├── config/ Pydantic settings
│ ├── core/
│ │ ├── clustering/ Fingerprint grouping, semantic merge, importance scoring
│ │ ├── compare/ Window diffing — new, disappeared, increased, decreased
│ │ ├── embeddings/ Provider abstraction + ingest persist helper
│ │ ├── explain/ Evidence assembly, templates, confidence, summarizer
│ │ ├── ingestion/ Ingestion orchestration, webhooks, batch persistence
│ │ ├── llm/ Provider abstraction (OpenAI, Ollama, noop)
│ │ ├── normalization/ Message normalization, fingerprinting, trigger patterns
│ │ ├── parsing/ JSON and text parsers, field extractors, timestamps
│ │ ├── retrieval/ Semantic + keyword question answering
│ │ ├── retention/ Per-scope TTL, scheduled purge of raw vs summary tiers
│ │ └── timeline/ Causal timeline reconstruction
│ ├── db/ SQLAlchemy models, session management
│ └── utils/ Time window parsing, hashing helpers
├── migrations/ Alembic migration scripts
├── clients/ OpenAPI spec + client codegen docs
├── sample_data/ Demo incident logs (deploy, billing, api)
└── tests/
├── unit/ Tests — parsers, normalization, clustering, time
└── integration/ Full ingest → cluster → explain flow (requires DB)
Adding a log source adapter
New source adapters go in src/adapters/ and implement SourceAdapter (discover / read), yielding RawLogLine objects that the existing parser maps onto ParsedLogLine. The normalization, fingerprinting, storage, clustering, and explain pipeline is fully source-agnostic.
Datadog adapter limits
- Auth:
DD-API-KEY+DD-APPLICATION-KEY(application key needslogs_read_data). Keys come from env only — never CLI--param. - Endpoint:
POST https://api.<site>/api/v2/logs/events/searchwith an absolutefrom/towindow (relative ranges drop events while paginating). - Pagination: cursor from
meta.page.after; resume with--resume-job. - Page size: default 1000, Datadog hard max 1000 (
--param page_size=NorDATADOG_PAGE_SIZE). - Max rows per run: default 10000 (
--param max_rows=NorDATADOG_MAX_ROWS). Hitting the cap saves the next cursor for resume. - Rate limits: HTTP 429 and 5xx are retried up to 3 times with exponential backoff; a persistent failure marks the job
ADAPTER_UNAVAILABLE(orpartial: trueif some events already landed). - Field mapping: Datadog
status→level;service/host/message/timestamppass through;envfrom theenv:tag or attributes;trace_id/request_idfrom nested custom attributes when present. Other nested Datadog attributes are dropped so core parsing stays source-agnostic.
MIT