Skip to main content

Analytics Endpoints

Endpoints under /api/analytics/* query the Elasticsearch-backed view of test results. All require analytics:dashboards:read permission (or higher); the configuration endpoints require analytics:settings:read / analytics:settings:write.

Use the Programmatic Access guide for examples

This page is the parameter reference. For curl examples, common recipes (CI gates, SIEM forwarders, drift detection), and a walkthrough of the three score variants, read the Programmatic Access guide.

Shared filter parameters

Most endpoints accept the same filter vocabulary. Pass any combination:

ParamTypeDescription
fromstringStart of window. Accepts ISO-8601 (2026-04-23T00:00:00Z), calendar dates (2026-04-23), or Elasticsearch date math (now-30d).
tostringEnd of window. Same formats as from.
orgstringFilter by organization UUID (Clerk org id).
testsstringComma-separated test UUIDs.
techniquesstringComma-separated MITRE ATT&CK technique IDs (e.g. T1486,T1059).
hostnamesstringComma-separated hostnames.
categoriesstringComma-separated test categories.
severitiesstringComma-separated severities: low, medium, high, critical.
threatActorsstringComma-separated threat-actor names.
tagsstringComma-separated tags.
errorNamesstringComma-separated error names (resolved from exit codes).
errorCodesstringComma-separated exit codes.
bundleNamesstringComma-separated bundle test names.
resultstringOne of: all, protected, unprotected, inconclusive.
scoringModestringall-stages (default — bundle protected only if every stage protected) or any-stage (bundle protected if any stage protected).
Default time window is 7 days

If you don't pass from/to, every endpoint applies a now-7d date filter. The dashboard UI defaults to Last 30 days — so the same query that fills the UI may return zero rows when called from the API without explicit dates. Always pass from (and optionally to) when scripting against the API.

Endpoints

Defense Score

GET /api/analytics/defense-score

Returns the aggregate defense score and breakdown counts for the filtered window.

Response shape:

{
"score": 54.59, // EDR-protected OR Defender-detected, risk-accepted excluded
"realScore": 53.21, // EDR-protected only, risk-accepted excluded
"rawScore": 53.83, // EDR-protected OR Defender-detected, WITHOUT risk-acceptance exclusion
"protectedCount": 1391, // strictly EDR-protected
"detectedCount": 36, // Defender caught (EDR missed)
"unprotectedCount": 1187, // neither
"totalExecutions": 2614, // = protected + detected + unprotected
"riskAcceptedCount": 37 // excluded from totalExecutions
}

See the guide for when to use each score variant.

Defense Score Trend

GET /api/analytics/defense-score/trend

Returns the score over time for trend visualization.

Additional parameters:

ParamTypeDescription
intervalstringHistogram bucket size (e.g. day, hour).
windowDaysnumberRolling window size, 1–90 days, applied per bucket.

Defense Score by Test / Technique / Org / Severity / Category / Hostname

GET /api/analytics/defense-score/by-test
GET /api/analytics/defense-score/by-technique
GET /api/analytics/defense-score/by-org
GET /api/analytics/defense-score/by-severity
GET /api/analytics/defense-score/by-category
GET /api/analytics/defense-score/by-category-subcategory
GET /api/analytics/defense-score/by-hostname

All accept the shared filter parameters and return protected/unprotected counts grouped by the named dimension.

Recent Executions

GET /api/analytics/executions

Flat list of recent test executions, enriched with catalog metadata. Returns at most limit rows.

Additional parameters:

ParamTypeDescription
limitnumberMax rows returned (default 50).

Response: array of EnrichedTestExecution (8 fields per row: test_uuid, test_name, hostname, is_protected, org, timestamp, error_code, error_name).

Paginated Executions

GET /api/analytics/executions/paginated

Filtered, grouped, paginated execution results. Accepts every shared filter plus pagination controls.

Additional parameters:

ParamTypeDescription
pagenumberPage number (1-indexed, default 1).
pageSizenumberResults per page (default 25).
sortFieldstringField to sort by.
sortOrderstringasc or desc.
groupedbooleanIf true, groups results by bundle/standalone identity.

Response shape depends on the grouped parameter:

Default — flat (grouped=false or omitted)

{
"data": [
{ /* one EnrichedTestExecution per ES document — ~20 fields incl. test_uuid, test_name,
hostname, is_protected, timestamp, error_code, error_name, category, severity,
techniques, tactics, score, bundle_id, control_id, is_bundle_control,
defender_detected, … */ }
],
"pagination": { "page": 1, "pageSize": 25, "totalItems": 480, "totalPages": 20, "hasNext": true, "hasPrevious": false }
}

One row per actual execution. Bundle tests appear as multiple rows (one per control, with is_bundle_control: true and a control_id). Best for SIEM forwarding, CSV exports, or any per-execution analysis.

?grouped=true — bundle-aware grouping

{
"groups": [
{
"groupKey": "standalone::<test_uuid>::<hostname>::<event_time_ms>",
"type": "standalone" | "bundle",
"representative": { /* one EnrichedTestExecution */ },
"members": [ /* all matching docs in this group */ ],
"protectedCount": 3,
"unprotectedCount": 1,
"totalCount": 4,
"defenderDetected": true
}
],
"pagination": { "page": 1, "pageSize": 25, "totalGroups": 142, "totalDocuments": 480, "totalPages": 6, "hasNext": true, "hasPrevious": false }
}

One row per "run" — bundle tests collapse from N controls into a single entry. Best for dashboards and rollups where you want to count "one bundle run" rather than N control checks.

Coverage Aggregates

GET /api/analytics/host-test-matrix       — heatmap data (host × test)
GET /api/analytics/technique-distribution — protected/unprotected per MITRE technique
GET /api/analytics/test-coverage — protected/unprotected per test
GET /api/analytics/threat-actor-coverage — coverage broken down by threat actor
GET /api/analytics/error-rate — error count and rate over the window
GET /api/analytics/error-rate/trend — error rate over time
GET /api/analytics/results-by-error-type — breakdown by error code/name

All accept the shared filter parameters.

Counts and Catalogs

GET /api/analytics/unique-hostnames       — distinct host count
GET /api/analytics/unique-tests — distinct test count
GET /api/analytics/canonical-test-count — count of canonical (deduped) tests
GET /api/analytics/organizations — known organizations
GET /api/analytics/available-tests — all tests with any execution
GET /api/analytics/executed-test-uuids — every test UUID that has been executed
GET /api/analytics/available-techniques — MITRE techniques with any data
GET /api/analytics/available-hostnames — hostnames seen in results
GET /api/analytics/available-categories
GET /api/analytics/available-severities
GET /api/analytics/available-threat-actors
GET /api/analytics/available-tags
GET /api/analytics/available-error-names
GET /api/analytics/available-error-codes
GET /api/analytics/available-bundle-names

These power the filter dropdowns in the UI. They accept the shared filter parameters but most ignore date filters by default so the dropdowns stay populated.

Archive Operations

POST /api/analytics/executions/archive          — archive by group keys
POST /api/analytics/executions/archive-by-date — archive everything before a date

Require analytics:executions:archive permission. See the Programmatic Access guide for the response envelope and idempotency notes.

Index Management

GET  /api/analytics/indices       — list configured indices
POST /api/analytics/index/create — create a new index (requires analytics:index:create)

Configure Elasticsearch

GET  /api/analytics/settings      — fetch current settings (masked)
POST /api/analytics/settings — save settings (requires analytics:settings:write)
POST /api/analytics/settings/test — test a candidate configuration

Stored encrypted at rest (AES-256-GCM) in ~/.projectachilles/analytics.json on filesystem deployments, or in Vercel Blob on serverless.

See also