원클릭으로
roadmap
Open roadmap issues (#1 observability/Grafana/Alertmanager,
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
Open roadmap issues (#1 observability/Grafana/Alertmanager,
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
Neo4j graph schema (Transaction-as-Node pattern, Entity/Trace/TokenTransfer labels, constraints/indexes), group entities rules, container startup sequence (entrypoint.sh), full REST API endpoint table (/health, /api/auth, /api/entities, /api/transactions, /api/groups, /api/pipeline, /api/ingestion-runs, /api/labels, /api/stats), PostgreSQL tables (entity_features, ingestion_runs, raw_transactions, known_labels, label_tasks, annotations, users), two-tier hot/warm storage strategy, and custom AML Cypher queries in graph/queries.py (peel chain, structuring, round trip, fan-out/fan-in, mixer interaction, high-risk paths). Invoke when adding/changing API routes, modifying graph schema, writing Cypher, touching PG tables, or explaining how data flows through Neo4j + PostgreSQL.
Code style rules per stack — Python backend (async Neo4j driver, Pydantic schemas, SQLAlchemy async + asyncpg, Alembic asyncio.run pattern, 204 No Content handling, JWT HTTPBearer); TypeScript frontend (useToastContext for user feedback, Cytoscape lazy-load, entity/tx node colors, SENT/RECEIVED edge separation, React Router v6 pages, Tailwind-only styling, API client noContent flag); Tailwind patterns (local class const strings like btnPrimary/inputCls, custom keyframes in tailwind.config.js, RISK_BADGE_CLASSES lookup); Neo4j Cypher (always parameterize, LIMIT clauses, quantified path patterns for multi-hop). Invoke when writing code in any of these stacks, refactoring, or reviewing a PR for style.
Rust ETL workspace under etl-rs/ — workspace crates (types, config, sources with BlockSource trait, sinks for Redis/Neo4j/Postgres/ClickHouse, pipeline retry/DLQ, consumer, ingest, worker, clickhouse), data-flow diagram (Etherscan → Redis streams → Neo4j+PG), `ingest` one-shot CLI subcommands (block, address, reprocess-failed, targeted addresses/neighborhood), `worker` long-running binary three tasks (A targeted_queue BRPOP, B refresh known_labels, C XREADGROUP stream consumer → graph+PG), build commands, and how to reset a stuck consumer group via XGROUP SETID. Invoke when touching anything under etl-rs/, modifying the ingest or worker binaries, changing Redis stream consumer logic, or debugging the ETL pipeline.
| name | roadmap |
| description | Open roadmap issues (#1 observability/Grafana/Alertmanager, |
Discrete, pickup-ready work items. Each has a problem statement, concrete acceptance criteria, and file-level pointers so a teammate can scope and start without a kickoff meeting. Ordered roughly by user-facing value / risk reduction.
Problem. Prometheus scrapes worker:9100 and Grafana is provisioned with the etl-overview dashboard, but in practice nobody looks at either. Alertmanager was deleted 2026-04-22 because it ran with the null receiver — we need it back with a real routing tree before Phase #3 (scaling workers) lands.
Acceptance criteria.
/metrics: worker, clickhouse-process (Phase #4), backend (add a FastAPI middleware), Dagster webserver (see Phase #5), Redis (via redis_exporter), Postgres (postgres_exporter), Neo4j (already exposes /metrics on 2004).worker task C dropped to 0 messages-processed over 10 min. Route to a Slack or Discord webhook (dev) and something escalatable (prod).compose/alertmanager/ — do NOT reintroduce the old null-receiver config.Problem. etl-rs has unit tests per crate but no end-to-end test that runs ingest → Redis → worker → Neo4j + Postgres as a whole. We've had two incidents where a stream consumer silently stalled because an upstream schema drift caused parse failures to accumulate, and neither was caught by cargo test.
Acceptance criteria.
etl-rs/crates/etl/tests/ that spins up Redis + Postgres + Neo4j via testcontainers-rs and exercises: block ingest → stream publish → worker drain → graph + PG assertions. Runs in CI on every push.MERGE, so this is mostly about retry / DLQ correctness).cargo bench target that ingests 10k blocks at fixed concurrency and reports throughput + p99 latency. Commit baseline numbers to etl-rs/bench/baseline.md so regressions show up in PR review.ingest dlq {list,replay,drop} --stream <name>. Right now a message in a DLQ is effectively lost — operators need a way to triage.worker scaling + data-source stabilityProblem. Deep-trace / address-fetch latency is dominated by a single worker task A (BRPOP on ingest:targeted_queue). One job blocks the next. The Etherscan free-tier limit (5 req/s) is already tight when a single user fires a 2-hop deep trace, and we have no rate-limit coordination across workers.
Acceptance criteria.
worker scales to N replicas via deploy.replicas in compose/etl.yml. Task A uses BRPOP (already safe for multi-reader); task B (refresh loop) needs a distributed lease (Redis SET NX EX) so only one replica refreshes at a time; task C (XREADGROUP) is already multi-consumer via the consumer group.BlockSource trait exists (etl-rs/crates/sources/src/lib.rs) but there's no composite / failover impl — build FailoverSource.redis-cell module or a Lua script). Per-provider budgets, configurable via env.label_tasks table — add a finished_at - pickup_at histogram to Prometheus.Problem. compose/infra.yml runs ClickHouse, migrations exist at etl-rs/crates/etl/migrations/clickhouse/, the clickhouse-process binary compiles, but nothing in docker compose up actually runs the consumer. Analyst queries like "top 100 CEX counterparties of address X in the last 30 days" are currently impossible without a table scan in Neo4j.
Acceptance criteria.
clickhouse-process service added to compose/etl.yml, consumer group chain-analysis-clickhouse drains ingested_txs, ingested_traces, ingested_transfers into chain_analysis.transactions / traces / token_transfers.clickhouse-process:9100./api/analytics/*): volume-over-time for an address, top counterparties, and per-day tx count histogram. Backed by ClickHouse, not Neo4j.Problem. Dagster is dormant. The only thing keeping it alive is reprocess_job + backfill_job, both manually triggered and both trivially reimplementable as a ingest reprocess-failed CLI invocation (which already exists). Meanwhile it consumes ~500 MB of container memory and adds two services to every local stack.
Acceptance criteria. Pick ONE of:
/metrics into Prometheus, model the always-on stream consumers as assets so lineage shows up in the UI, wire Dagster alerts into the Phase #1 Alertmanager.compose/dagster.yml, the backend/src/etl/ Dagster module, the Rust-builder stage from backend/Dockerfile. Replace reprocess_job/backfill_job with cron-invoked ingest CLI runs (or a lightweight tokio-cron loop inside worker). Update Phase #1 dashboard and Phase N (now #4) accordingly.Recommendation: (b) unless someone on the team is actively building asset lineage. The current hybrid state is the worst of both worlds.
Problem. Operators currently need three tabs open: Grafana (metrics), Dagster UI (run state), and raw redis-cli XINFO STREAM/LLEN for queue depth. No single page answers "is the system healthy, and if not, where is the blockage?"
Acceptance criteria.
/ops page in the frontend (admin-gated). Live-updating cards:
ingest:targeted_queue (LLEN), every ingested_* stream (XLEN), every DLQ.ingestion_runs + last 10 completed (already exist; just move them here).worker_heartbeat_total{replica="<id>"} metric + a Postgres worker_replicas table that worker writes to every 10s. Backend reads the table for the Ops page./api/ops/* endpoints backed by Prometheus HTTP API (http://prometheus:9090/api/v1/query) + direct DB reads. No scraping state in the backend.This is a multi-week effort — treat it as a separate track from #1 (which sets up the data), not a follow-up.
Problem. Backend logs via libs/logger.py (semi-structured), etl-rs via tracing (structured), frontend via console.* (unstructured). No correlation ID threads across the three. When a user reports "the deep trace didn't finish," there's no way to follow the request from click → API → Redis → worker → Neo4j.
Acceptance criteria.
ts, service, level, msg, trace_id, span_id, user_id?, request_id?.request_id per user action and threads it through every fetch call as X-Request-Id. Backend propagates into Redis message payloads (meta.request_id). Worker logs it on every stream-message processing log line.tracing-opentelemetry + a Jaeger/Tempo backend only if there's demand.Problem. Neo4j + Postgres + ClickHouse run on named Docker volumes with zero backup. A docker compose down -v wipes everything. In prod this is an unacceptable DR posture.
Acceptance criteria.
pg_dump -Fc → compressed archive.neo4j-admin database dump → tarball (requires stopping the DB or using online backup on Enterprise — document which).BACKUP DATABASE chain_analysis TO Disk('backups').docs/runbooks/restore.md with tested commands./health is green. Commit the drill result to docs/runbooks/drill-log.md.Problem. GraphExplorerPage renders every node + transaction node returned by the backend. Pull in a 2-hop neighborhood of a Binance hot wallet (thousands of neighbours) and Cytoscape chokes — layout takes 10+ s, pan/zoom drops to <10 fps, the panel is unreadable. There's no affordance for "collapse all these smurf EOAs into one group node."
Acceptance criteria.
tx.value. "Load N more" button, or "Load all (N)" with a confirmation when N > 500.f = fit, / = focus search, e = expand selected node, c = collapse cluster. Document them in a ? overlay.perf.now() render hooks; commit numbers to frontend/perf/baseline.md.Problem. POST /api/pipeline/ingest-address (Fetch Transactions) and POST /api/labels/fetch (Deep Trace) currently let Etherscan default to sort=asc, so the first ingest of a whale wallet returns 2017-era txs (oldest 500 within the cap). Users see "ancient activity" instead of "what is this address doing now". The tx_limit=500 cap from feature/etl-stability stops the timeout but doesn't change the chronological direction.
Proposed scheme. Track an ingested-block range per address as (first_synced_block, last_synced_block) = (L, R). On each user-triggered ingest:
sort=desc, no start/end). Record L = min(block), R = max(block).sort=asc, start_block=R+1, cap=500. If results, ingest and bump R.sort=desc, end_block=L-1, cap=500. Ingest, lower L. If L == 1 then fully synced.Each click costs at most 2 Etherscan calls (forward + maybe backward fallback).
Acceptance criteria.
first_synced_block BIGINT NOT NULL DEFAULT 0 to entity_features.etl-rs/crates/etl/src/sources/etherscan/address.rs::fetch_address_transactions accepts a sort parameter (asc | desc), default preserves current behaviour.effective_from_block in etl-rs/crates/etl/src/ingest/targeted.rs becomes effective_fetch_window returning (sort, start_block, end_block) based on the per-address [L, R] read from PG.ingest_address_capped plumbs sort through to the source layer; result handler updates both L = LEAST(existing, batch_min) and R = GREATEST(existing, batch_max) via postgres_writer.pipeline.py, labels.py deep-trace path, worker Task A. Task B refresh stays on sort=asc, start=R+1 — its goal is contiguous catch-up, not latest-first.L > 1), fully-synced (L == 1).Out of scope. Frontend "Fetch more" affordance to make the multi-click chain explicit. Good follow-up; not blocking.
Estimated effort. ~2 hours including migration + tests. Background context in the 2026-05-08 chat following feature/etl-stability's tx_limit cap work.
Smaller issues that don't warrant their own phase but shouldn't rot:
backend/src/graph/queries.py (peel chain, structuring, round trip, fan-out/fan-in, mixer interaction) still have no API route and no UI. Build /api/detections/{pattern}?address=… endpoints and a Detections tab that highlights matching subgraphs on the Cytoscape canvas. Blocked on #9 if the address has many neighbours.backend/pytest and etl-rs/cargo test. Do this AFTER #9 so selectors don't churn.JWT_SECRET_KEY env var with no rotation story, tokens live 24 h in localStorage (XSS-exfiltratable), no refresh-token flow, no MFA, no password-reset. For a pre-prod tool this is fine; before a real external user touches it, at minimum: rotate-on-deploy, httpOnly cookies, short access + long refresh./api/pipeline/ingest-address and /api/labels/tasks are unauthenticated against abuse. One user with a loop can saturate the Etherscan budget. Add slowapi or equivalent, per-user limits backed by Redis..env.example has real defaults (password123, clickhouse123). Move all "local dev only" secrets to a single compose/secrets.dev.env that's .gitignore'd, and fail-fast in prod if any default leaks in.init_neo4j.py is the current source of truth but runs idempotently on every boot. A real versioned migration system (e.g. neo4j-migrations) would make schema changes reviewable. Low priority until schema churns again.scripts/seed_neo4j.py now uses real mainnet txs (rewrite 2026-04-22) but the old synthetic peel-chain / fan-out / TC-group patterns went away with it. The AML detection queries above won't light up on the new seed. Either re-add a small synthetic "laundering-demo" subgraph under a distinct :Demo label, or ingest a known real-world incident (e.g. the Ronin exploiter's peel chain) as part of the demo bootstrap.Cytoscape + fcose + react-router + Tailwind JIT = ~800 kB gzipped. Code-split the Graph page; lazy-load Cytoscape on route entry.backend/src/api/routes/* has inconsistent Pydantic response-model coverage. Audit and enforce response_model= on every route so the OpenAPI schema is trustworthy.worker task B (refresh loop) has no metrics at all. Task A doesn't record "time from enqueue to pickup." Add these in lockstep with #3.make bootstrap that walks through key provisioning, seeds a realistic dataset, and opens the UI.| Date | Tool | Reason |
|---|---|---|
| 2026-04-22 | MinIO object store + minio-init | Cold-tier Parquet export (old Phase K) never built; three-tier → two-tier. Re-introduce an object store when ML training / compliance retention lands. |
| 2026-04-22 | Alertmanager + alerts.yml | Ran with the null receiver; never routed a real alert. Prometheus alone is sufficient for local dev. |
| 2026-04-22 | ingest-builder one-shot + rust_bin volume | Rolled into the Dagster image via multi-stage COPY in backend/Dockerfile. |
| Phase I | process binary | Absorbed into worker task C. |
| Phase I | targeted_queue_sensor, targeted_drain_job | Replaced by worker task A. |