Skip to content

Commit cb6d516

Browse files
committed
feat: timeline
1 parent 386a0a5 commit cb6d516

File tree

7 files changed

+777
-404
lines changed

7 files changed

+777
-404
lines changed

sample_data/sample_incident/api.log

Lines changed: 144 additions & 144 deletions
Large diffs are not rendered by default.

sample_data/sample_incident/billing-worker.log

Lines changed: 250 additions & 250 deletions
Large diffs are not rendered by default.
Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
{"timestamp": "2026-03-12T21:37:14.282406+00:00", "level": "info", "service": "deployment-controller", "message": "Starting deployment for billing-worker version v2.4.1"}
2-
{"timestamp": "2026-03-12T21:37:44.282406+00:00", "level": "info", "service": "deployment-controller", "message": "Pulling image billing-worker:v2.4.1"}
3-
{"timestamp": "2026-03-12T21:38:59.282406+00:00", "level": "info", "service": "deployment-controller", "message": "Rolling out billing-worker v2.4.1 \u2014 0/3 pods ready"}
4-
{"timestamp": "2026-03-12T21:39:24.282406+00:00", "level": "info", "service": "deployment-controller", "message": "Rolling out billing-worker v2.4.1 \u2014 1/3 pods ready"}
5-
{"timestamp": "2026-03-12T21:39:54.282406+00:00", "level": "info", "service": "deployment-controller", "message": "Rolling out billing-worker v2.4.1 \u2014 2/3 pods ready"}
6-
{"timestamp": "2026-03-12T21:40:14.282406+00:00", "level": "info", "service": "deployment-controller", "message": "Rolling out billing-worker v2.4.1 \u2014 3/3 pods ready"}
7-
{"timestamp": "2026-03-12T21:44:14.282406+00:00", "level": "info", "service": "deployment-controller", "message": "Deploy completed for billing-worker version v2.4.1"}
8-
{"timestamp": "2026-03-12T21:44:15.282406+00:00", "level": "info", "service": "billing-worker", "message": "Application started billing-worker v2.4.1 on port 8080"}
9-
{"timestamp": "2026-03-12T21:44:16.282406+00:00", "level": "info", "service": "billing-worker", "message": "Connected to database postgresql://billing-db:5432/billing"}
10-
{"timestamp": "2026-03-12T21:44:17.282406+00:00", "level": "info", "service": "billing-worker", "message": "Stripe webhook handler initialized for endpoint /webhooks/stripe"}
1+
{"timestamp": "2026-03-12T22:16:56.125963+00:00", "level": "info", "service": "deployment-controller", "message": "Starting deployment for billing-worker version v2.4.1"}
2+
{"timestamp": "2026-03-12T22:17:26.125963+00:00", "level": "info", "service": "deployment-controller", "message": "Pulling image billing-worker:v2.4.1"}
3+
{"timestamp": "2026-03-12T22:18:41.125963+00:00", "level": "info", "service": "deployment-controller", "message": "Rolling out billing-worker v2.4.1 \u2014 0/3 pods ready"}
4+
{"timestamp": "2026-03-12T22:19:06.125963+00:00", "level": "info", "service": "deployment-controller", "message": "Rolling out billing-worker v2.4.1 \u2014 1/3 pods ready"}
5+
{"timestamp": "2026-03-12T22:19:36.125963+00:00", "level": "info", "service": "deployment-controller", "message": "Rolling out billing-worker v2.4.1 \u2014 2/3 pods ready"}
6+
{"timestamp": "2026-03-12T22:19:56.125963+00:00", "level": "info", "service": "deployment-controller", "message": "Rolling out billing-worker v2.4.1 \u2014 3/3 pods ready"}
7+
{"timestamp": "2026-03-12T22:23:56.125963+00:00", "level": "info", "service": "deployment-controller", "message": "Deploy completed for billing-worker version v2.4.1"}
8+
{"timestamp": "2026-03-12T22:23:57.125963+00:00", "level": "info", "service": "billing-worker", "message": "Application started billing-worker v2.4.1 on port 8080"}
9+
{"timestamp": "2026-03-12T22:23:58.125963+00:00", "level": "info", "service": "billing-worker", "message": "Connected to database postgresql://billing-db:5432/billing"}
10+
{"timestamp": "2026-03-12T22:23:59.125963+00:00", "level": "info", "service": "billing-worker", "message": "Stripe webhook handler initialized for endpoint /webhooks/stripe"}

src/cli/commands/timeline.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""
2+
raglogs timeline — reconstruct the sequence of events in an incident window.
3+
4+
Output example:
5+
6+
Incident timeline 2026-03-12 14:07 → 14:15 UTC
7+
8+
14:07:26 deploy Deploy completed for billing-worker v2.4.1
9+
14:07:27 startup billing-worker started on port 8080
10+
11+
14:09:26 error ↑ Stripe signature verification failed (/webhooks/stripe)
12+
184 events · billing-worker · 6 min span
13+
14+
14:09:29 effect POST /api/checkout 500 (upstream billing error)
15+
39 events · api
16+
17+
14:09:31 effect Checkout latency increased
18+
25 events · api
19+
20+
14:09:35 symptom Webhook queue grew to 168 pending items
21+
2 events · billing-worker
22+
"""
23+
from typing import Optional
24+
25+
import typer
26+
from rich.console import Console
27+
from rich.text import Text
28+
from rich import box
29+
from rich.table import Table
30+
31+
console = Console()
32+
33+
# Colour palette per category
34+
CATEGORY_STYLE = {
35+
"deploy": "bold cyan",
36+
"startup": "cyan",
37+
"trigger": "bold yellow",
38+
"error": "bold red",
39+
"effect": "yellow",
40+
"symptom": "dim yellow",
41+
}
42+
43+
44+
def timeline_cmd(
45+
since: Optional[str] = typer.Option(None, "--since", help="Time window e.g. 30m, 1h, 24h"),
46+
from_time: Optional[str] = typer.Option(None, "--from", help="Start time (ISO 8601)"),
47+
to_time: Optional[str] = typer.Option(None, "--to", help="End time (ISO 8601)"),
48+
service: Optional[str] = typer.Option(None, "--service", help="Filter by service"),
49+
env: Optional[str] = typer.Option(None, "--env", help="Filter by environment"),
50+
all_ingestions: bool = typer.Option(False, "--all-ingestions", help="Include all ingestion data"),
51+
ingestion_job: Optional[str] = typer.Option(None, "--ingestion-job", help="Scope to specific ingestion job UUID"),
52+
fmt: str = typer.Option("text", "--format", help="Output format: text|json"),
53+
):
54+
"""Reconstruct the sequence of events in an incident window."""
55+
import uuid
56+
from datetime import datetime
57+
58+
from src.core.clustering.clusterer import run_clustering
59+
from src.core.explain.evidence import assemble_evidence
60+
from src.core.explain.summarizer import get_latest_ingestion_job_id
61+
from src.core.timeline.builder import build_timeline
62+
from src.db.session import get_db
63+
from src.utils.time import format_window, resolve_window
64+
65+
try:
66+
from_dt = datetime.fromisoformat(from_time) if from_time else None
67+
to_dt = datetime.fromisoformat(to_time) if to_time else None
68+
window_start, window_end = resolve_window(since=since, from_time=from_dt, to_time=to_dt)
69+
except ValueError as e:
70+
console.print(f"[red]Error:[/red] {e}")
71+
raise typer.Exit(1)
72+
73+
with console.status("[cyan]Reconstructing timeline...[/cyan]"):
74+
try:
75+
with get_db() as db:
76+
job_id = None
77+
if ingestion_job:
78+
job_id = uuid.UUID(ingestion_job)
79+
elif not all_ingestions:
80+
job_id = get_latest_ingestion_job_id(db)
81+
82+
_, clusters = run_clustering(
83+
db=db,
84+
window_start=window_start,
85+
window_end=window_end,
86+
service=service,
87+
environment=env,
88+
save_to_db=False,
89+
ingestion_job_id=job_id,
90+
)
91+
92+
packet = assemble_evidence(
93+
db=db,
94+
window_start=window_start,
95+
window_end=window_end,
96+
clusters=clusters,
97+
service_filter=service,
98+
environment_filter=env,
99+
ingestion_job_id=job_id,
100+
)
101+
102+
events = build_timeline(packet)
103+
104+
except Exception as e:
105+
console.print(f"[red]Error:[/red] {e}")
106+
raise typer.Exit(1)
107+
108+
if fmt == "json":
109+
import json
110+
output = [
111+
{
112+
"timestamp": e.timestamp.isoformat(),
113+
"category": e.category,
114+
"description": e.description,
115+
"count": e.count,
116+
"services": e.services,
117+
"duration_minutes": e.duration_minutes,
118+
}
119+
for e in events
120+
]
121+
console.print_json(json.dumps(output, default=str))
122+
return
123+
124+
_render_text(events, window_start, window_end)
125+
126+
127+
def _render_text(events, window_start, window_end):
128+
from src.utils.time import format_window
129+
130+
console.print()
131+
console.print(
132+
f"[bold]Incident timeline[/bold] "
133+
f"[dim]{format_window(window_start, window_end)}[/dim]"
134+
)
135+
console.print()
136+
137+
if not events:
138+
console.print("[dim] No significant events found in this window.[/dim]")
139+
console.print()
140+
return
141+
142+
# Group events: insert a blank line when timestamp gap > 1 minute
143+
prev_ts = None
144+
for event in events:
145+
# Blank separator on time gap > 60s
146+
if prev_ts is not None:
147+
gap = (event.timestamp - prev_ts).total_seconds()
148+
if gap > 60:
149+
console.print()
150+
151+
ts_str = event.timestamp.strftime("%H:%M:%S")
152+
label = event.label
153+
style = CATEGORY_STYLE.get(event.category, "white")
154+
155+
# Point-in-time events (no count): append service inline
156+
if event.count is None:
157+
svc = " · ".join(event.services)
158+
suffix = f" [dim]· {svc}[/dim]" if svc else ""
159+
console.print(
160+
f" [dim]{ts_str}[/dim] "
161+
f"[{style}]{label:<10}[/{style}] "
162+
f"{event.description}{suffix}"
163+
)
164+
else:
165+
# Volumetric events: main line + sub-line with count · service · duration
166+
console.print(
167+
f" [dim]{ts_str}[/dim] "
168+
f"[{style}]{label:<10}[/{style}] "
169+
f"{event.description}"
170+
)
171+
parts = []
172+
plural = "s" if event.count != 1 else ""
173+
parts.append(f"{event.count} event{plural}")
174+
if event.services:
175+
parts.append(", ".join(event.services))
176+
if event.duration_minutes:
177+
parts.append(f"{event.duration_minutes} min span")
178+
sub = " · ".join(parts)
179+
console.print(f" [dim]{sub}[/dim]")
180+
181+
prev_ts = event.timestamp
182+
183+
console.print()

src/cli/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def _build_app() -> typer.Typer:
1414
from src.cli.commands.ask import app as ask_app
1515
from src.cli.commands.demo import demo_cmd
1616
from src.cli.commands.worker import worker_cmd
17+
from src.cli.commands.timeline import timeline_cmd
1718

1819
_app = typer.Typer(
1920
name="raglogs",
@@ -30,6 +31,7 @@ def _build_app() -> typer.Typer:
3031
_app.command("config")(config_cmd)
3132
_app.command("demo")(demo_cmd)
3233
_app.command("worker")(worker_cmd)
34+
_app.command("timeline")(timeline_cmd)
3335
_app.add_typer(ask_app, name="ask")
3436
return _app
3537

src/core/timeline/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)