Analytics events
Every event is an Otoroshi AnalyticEvent, so it flows through any data exporter — Elastic, Kafka,
a webhook, a file — with no extra wiring.
If you only wire one of them up, wire up the first.
All of them carry Otoroshi's envelope: @serviceId and @service are the id and name of the route
the request was on, and @env is Otoroshi's app.env. The route is -- only where there is none:
the incoming request validator runs before routing.
CloudApimSecurityEvent
The normalised one. Every component of the fabric — the threat response, the honeypot, fail2ban, a challenge, a ban — emits this shape rather than its own, so a SIEM needs one parser and one set of dashboards.
The envelope is ECS-shaped, so event.category, event.action, event.outcome and source.ip
land where an Elastic pipeline already looks for them.
{
"@type": "CloudApimSecurityEvent",
"@timestamp": 1757340000000,
"@product": "otoroshi",
"event": {
"kind": "alert", "module": "cloud-apim.security-suite",
"dataset": "cloud-apim.threat", "category": "threat",
"action": "tarpit", "outcome": "observed", "severity": 2
},
"source": { "ip": "203.0.113.9", "apikey": null, "user": null },
"threat": {
"score": 75,
"tags": ["reputation:firehol1", "waf:match", "bot:seo"],
"signals": [
{ "source": "reputation.feed", "kind": "reputation", "weight": 50,
"tag": "reputation:firehol1", "confidence": 1.0, "effective_weight": 50 }
]
},
"otoroshi": { "route_id": "route_...", "route_name": "public-api", "node": "otoroshi-node-1" },
"incident": { "id": "...", "count": 34 },
"message": "score 75 reached tier 70 (tarpit)",
"decision": { "action": "tarpit", "score": 75, "tier": 1, "dry_run": true, "enforced": false, "held_ms": 0 }
}
| Field | Meaning |
|---|---|
event.category | Which component decided: threat, honeypot, fail2ban, challenge, ban, traffic, api, upload, login, objects, leakage or sensitive_data |
event.action | allow, log, tarpit, challenge, deny, ban, or mask for a leaking response replaced by a neutral error and for sensitive values masked in place. A response the sensitive data guard refuses is a deny |
event.outcome | blocked when it was enforced, observed in dry run |
event.severity | 1 to 4, derived from the score |
threat.signals[] | Full attribution — which detector contributed what, and how sure it was |
incident.count | How many events this identity has already produced in the window |
decision.enforced | The unambiguous answer to "did this actually do anything" |
decision.held_ms | How long the request was held before the action took effect: a tarpit, or a slow refusal. 0 when it was not |
outcome: "observed" with a non-log action is the dry-run equivalent of "this would have been
enforced". Counting those over a week of real traffic is what tells you whether your tiers are set
correctly — the same discipline as blocking: false on the WAF trail event below.
CloudApimWafTrailEvent
Emitted once per request where at least one WAF rule matched. The main signal.
{
"@type": "CloudApimWafTrailEvent",
"@timestamp": 1757340000000,
"blocking": true,
"block": { "status": 403, "msg": "SQL Injection Attack Detected via libinjection" },
"events": [
{ "rule_id": 942100, "msg": "SQL Injection Attack Detected via libinjection", "phase": 2 }
],
"route": { "id": "route_...", "name": "public-api" },
"request": { "method": "GET", "uri": "/search?q=...", "headers": {} }
}
| Field | Meaning |
|---|---|
blocking | Whether the configuration is in blocking mode |
block | Non-null when the ruleset reached a deny decision |
events[] | Every rule that matched |
truncated | The body was longer than the inspection limit, so only its beginning was seen |
oversize_rejected | The request was refused for being past the limit, without being inspected |
rejected_reason | Why a body was refused before any rule read it: decompressed_size, compression_ratio, undecodable_encoding, corrupt_body |
blocking: false with a non-null block is the "would have been denied" case — the number to
track while tuning.
CloudApimWafReputationEvent
Emitted whenever a reputation lookup produced at least one hit, in monitoring mode too.
{
"@type": "CloudApimWafReputationEvent",
"blocked": true,
"mode": "block",
"verdict": {
"ip": "203.0.113.7",
"score": 95,
"blocking": true,
"tags": ["feed:firehol-l1", "asn:aws"],
"hits": [
{ "kind": "feed", "source_id": "threat-feed_...", "source_name": "FireHOL level 1",
"weight": 80, "blocking": true }
]
}
}
verdict.tags is the field to group on: it carries the feed tags and CrowdSec origin, so a single
query answers "what is each source actually catching".
CloudApimWafAuditEvent
Emitted by SecLang auditlog actions. Rule-level audit output, considerably more verbose than the
trail event — useful when debugging a specific rule, noisy as a permanent stream.
{
"@type": "CloudApimWafAuditEvent",
"@serviceId": "route_...",
"@service": "public-api",
"@env": "prod",
"rule_id": 942100,
"phase": 2,
"msg": "SQL Injection Attack Detected via libinjection",
"logdata": []
}
CloudApimWafTuningAudit
An Otoroshi audit event, emitted when the tuning assistant writes an exclusion. It records the decision and the evidence together.
{
"@type": "CloudApimWafTuningAudit",
"audit": "WAF_EXCLUSION_APPLIED",
"user": { "name": "Ada", "email": "ada@example.com" },
"config": "waf-config_x",
"rule_id": 942100,
"target": "ARGS:comment",
"kind": "update_target",
"seclang": "SecRuleUpdateTargetById 942100 \"!ARGS:comment\"",
"reason": "the comment field legitimately contains SQL snippets",
"ruleset": { "id": "waf-ruleset_y", "created": true },
"preview": { "reproduced": true, "effective": true, "regressions": [], "collateral_rules": [] },
"forced": false
}
preview is the measured consequence at the moment of the decision — what was verified, and what
was knowingly given up. forced is true only when the operator overrode a warning.
CloudApimWafLearningAudit
Emitted when exclusions are applied from a learning run. It carries the window the decision was taken on, not only the change.
{
"@type": "CloudApimWafLearningAudit",
"audit": "WAF_LEARNING_APPLIED",
"user": { "name": "Ada", "email": "ada@example.com" },
"config": "waf-config_x",
"applied": ["942100|ARGS:comment|/api/posts"],
"run": { "requests": 41203, "matched": 380, "would_block": 96, "duration_millis": 604800000 },
"impact": { "sampled": 200, "resolved": 187, "residual": 13 },
"paranoia": { "current": 1, "recommended": null },
"threshold": { "current": 5, "recommended": 15 }
}
CloudApimWafSecurityAudit
Emitted for every operator action taken on the Bans & incidents console — a ban, an extension, an unban, an allowlist entry, or an incident moved to a new state.
{
"@type": "CloudApimWafSecurityAudit",
"audit": "SECURITY_OPERATOR_ACTION",
"user": { "name": "Ada", "email": "ada@example.com" },
"action": "allowlist",
"ref": "apikey:partner-ci",
"detail": { "reason": "nightly integration suite", "until": null, "released": true }
}
action is one of ban, ban-refused, extend, unban, unban-all, allowlist,
allowlist-remove, incident-acknowledged, incident-resolved or incident-open. A refused ban is
audited too: knowing that someone tried to ban a caller the allowlist protects is worth as much as
knowing they succeeded.
CloudApimSecurityAlert
One per alert an alert rule sends, once for the whole cluster, whatever its channel: route it with a data exporter to anything Otoroshi reaches, a mailer included.
{
"@type": "CloudApimSecurityAlert",
"event": { "kind": "alert", "dataset": "cloud-apim.alert", "category": "incident", "severity": 3 },
"alert": {
"rule": { "id": "alert-rule_1f3a...", "name": "Enforced attacks" },
"trigger": "incident",
"key": "incident:ip:203.0.113.7",
"level": "high",
"title": "Incident on ip 203.0.113.7: score 90",
"summary": "waf block on /login",
"identity": { "kind": "ip", "value": "203.0.113.7" },
"facts": { "Score": "90", "Decisions": "12, 12 enforced" },
"details": { "...": "the incident, the ban, or the burst count" },
"link": "https://otoroshi.example.com/bo/dashboard/extensions/cloud-apim/waf/security"
}
}
CloudApimSecurityOcsf
Emitted only when security.events.ocsf is on: every CloudApimSecurityEvent and every alert again,
as an OCSF 1.3 Detection Finding (class 2004), for Security Lake, Sentinel and the other SIEMs
that read OCSF. A decision says what was done in OCSF's terms: action_id 2 and disposition_id 2
when it was enforced, 3 and 15 when it was only observed, 4 and 11 for a masked response. The
fabric's own fields that OCSF has no place for are under unmapped. The ECS-shaped events stay what
the console reads; this is an additional stream.
Suggested exporters
| Goal | Filter on |
|---|---|
| The one dashboard to build first | @type: CloudApimSecurityEvent, group by event.category and event.action |
| Alerting without a pager storm | @type: CloudApimSecurityAlert, or an alert rule straight to the channel |
| A SIEM that reads OCSF | @type: CloudApimSecurityOcsf, with security.events.ocsf on |
| Is the policy ready to arm? | @type: CloudApimSecurityEvent, outcome: observed and event.action != log |
| Attack dashboard | @type: CloudApimWafTrailEvent, group by events[].rule_id |
| Tuning backlog | blocking: false and block != null, group by rule id |
| Reputation coverage | @type: CloudApimWafReputationEvent, group by verdict.tags |
| Alerting | blocked: true or blocking: true with block != null, over a burst window |
| Who changed what during the incident | @type: CloudApimWafSecurityAudit, group by user.email |