Audit & observability¶
Three signal sources¶
- Regular logs —
tracingoutput to stderr / journald, levels DEBUG/INFO/WARN/ERROR, for development and debugging. - Audit JSONL — events tagged
target=futu_auditonly, for compliance, post-hoc forensics, attack investigation. - Prometheus metrics — counter aggregates for alerting + dashboards.
Audit JSONL¶
Enable:
futu-opend --audit-log /var/log/futu-audit.jsonl
# or
futu-opend --audit-log /var/log/futu/ # directory → daily rotating futu-audit.log.YYYY-MM-DD
futu-mcp / futucli take the same flag.
Event schema¶
{
"timestamp": "2026-04-15T10:23:45.123Z",
"level": "WARN", // reject=WARN, allow=INFO, trade=WARN
"target": "futu_audit",
"iface": "rest" | "grpc" | "ws" | "mcp" | "cli",
"endpoint": "/api/order" | "proto_id=2202" | "futu_place_order",
"key_id": "bot_a" | "<missing>" | "<invalid>" | "<none>",
"outcome": "allow" | "reject" | "success" | "failure",
"reason": "limit: rate limit exceeded: 5 in 60s (cap 3)",
"scope": "trade:real", // on allow
"args_hash": "8a3f2b9c" // on trade events: first 8 hex of SHA-256
}
Common jq queries¶
# recent rejects
jq 'select(.outcome=="reject")' /var/log/futu-audit.jsonl | tail -20
# orders from a specific key
jq 'select(.key_id=="bot_a" and .endpoint|test("order|place|modify"))' \
/var/log/futu-audit.jsonl
# reject reason histogram
jq -r 'select(.outcome=="reject") | .reason' /var/log/futu-audit.jsonl \
| awk -F': ' '{print $1}' \
| sort | uniq -c | sort -rn
# request distribution per iface
jq -r '.iface' /var/log/futu-audit.jsonl | sort | uniq -c
Batch analysis with DuckDB¶
-- load JSONL
CREATE TABLE audit AS SELECT * FROM read_json_auto('/var/log/futu-audit.jsonl');
-- daily order count per key
SELECT DATE(timestamp) AS day, COUNT(*) AS orders
FROM audit
WHERE key_id = 'bot_a' AND endpoint LIKE '%order%'
GROUP BY day;
Prometheus metrics¶
Scrape config:
prometheus.yml
scrape_configs:
- job_name: futu-opend
static_configs: [{ targets: ['opend:22222'] }]
- job_name: futu-mcp
static_configs: [{ targets: ['mcp:38765'] }]
Three counters¶
# HELP futu_auth_events_total Auth / trade events by iface, outcome, key_id
# TYPE futu_auth_events_total counter
futu_auth_events_total{iface="rest",outcome="allow",key_id="bot_a"} 1234
# HELP futu_auth_limit_rejects_total Limit-check rejects by iface, key_id, reason
# TYPE futu_auth_limit_rejects_total counter
futu_auth_limit_rejects_total{iface="grpc",key_id="bot_b",reason="rate"} 7
# HELP futu_ws_filtered_pushes_total Pushes filtered out for client lacking scope
# TYPE futu_ws_filtered_pushes_total counter
futu_ws_filtered_pushes_total{required_scope="trade",key_id="bot_c"} 42
Reason buckets¶
The reason label on futu_auth_limit_rejects_total is a finite set:
| reason | Meaning |
|---|---|
rate |
Rate-limit exceeded |
daily |
Daily cap exceeded |
per_order |
Per-order cap exceeded |
market |
Market whitelist |
symbol |
Symbol whitelist |
side |
Direction whitelist |
hours |
Time window |
other |
Other (not covered by classify_limit_reason) |
Alert rule examples¶
alerts.yml
groups:
- name: futu-opend
rules:
- alert: FutuAuthRejectSpike
expr: rate(futu_auth_events_total{outcome="reject"}[5m]) > 10
for: 5m
annotations:
summary: "Auth reject rate high ({{ $value }}/s)"
description: "possibly an attack or misconfigured key"
- alert: FutuRateLimitFrequent
expr: rate(futu_auth_limit_rejects_total{reason="rate"}[15m]) > 1
for: 15m
annotations:
summary: "Key {{ $labels.key_id }} repeatedly hitting rate limit"
- alert: FutuDailyCapNearLimit
expr: futu_auth_limit_rejects_total{reason="daily"} > 5
for: 5m
annotations:
summary: "Key {{ $labels.key_id }} hit the daily cap multiple times today"
Grafana dashboard¶
curl -LO https://futuapi.com/deploy/grafana-dashboard.json
# Grafana UI: Dashboards → Import → upload JSON
(The JSON file ships with the website.)
- Request rate by iface —
sum by (iface) (rate(futu_auth_events_total[5m])) - Allow vs Reject — two stacked lines
- Top rejected keys —
topk(10, sum by (key_id) (rate(futu_auth_events_total{outcome="reject"}[1h]))) - Limit reject breakdown — pie chart by
reason - WS filter dropped —
sum by (required_scope) (rate(futu_ws_filtered_pushes_total[5m]))
How they fit together¶
- Day-to-day monitoring → Grafana dashboard
- Alert fires → look up the specific event in audit JSONL
- Deep investigation → audit JSONL + DuckDB / jq
Metrics cover trends (numeric aggregates), audit covers forensics (specific events), and logs cover debugging (why did it fail).