NIS2 KPI Telemetry Schema v1
Contract ID: CONTRACT-NIS2-KPI-TELEMETRY-V1-019 Status: Active (Stella Ops EU compliance sprint 060) Owner: Telemetry Guild Schema version: nis2-kpi-telemetry-schema-v1 Source defaults: docs/europe/nis2-kpi-targets-defaults.yaml Input contract: nis2-control-register-v1
Part of the Stella Ops Contracts index. Audience: Telemetry implementers who own the NIS2 metric family, fact-owning modules (Policy, Findings, Authority, Crypto) that emit samples, and Console/dashboard teams that consume the read projection.
Purpose
This contract defines the Prometheus/OpenTelemetry metric family used to measure NIS2 effectiveness across the thirteen thematic areas from the EU compliance closure plan. The catalog is pinned to docs/europe/nis2-kpi-targets-defaults.yaml; tenant target overrides are configuration owned by downstream compliance-profile work and must not change the metric names.
The read projection (nis2-effectiveness-dashboard-v1, see Consumption) is in turn the input to the NIS2 Effectiveness Report monthly export.
Schema Pinning
- Schema id:
nis2-kpi-telemetry-schema-v1 - Pinning policy: pinned per Stella minor release; do not auto-follow external or YAML changes without contract review.
- Deterministic fixture location:
src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core.Tests/Nis2AreaMetricsServiceTests.cs - Runtime implementation (all under
src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/):- Service interface
INis2AreaMetricsServiceand concreteNis2AreaMetricsService(Nis2AreaMetricsService.cs). - OpenTelemetry gauge surface
Nis2AreaMetrics(Nis2AreaMetrics.cs); the meter name isStellaOps.NIS2(Nis2AreaMetrics.MeterName). - Pinned catalog
Nis2AreaMetricCatalogand the records/enums (Nis2AreaMetricDefinition,Nis2AreaMetricMeasurement,Nis2AreaMetricsReport,Nis2EvidenceMode,Nis2MetricUnit,Nis2ThresholdDirection,Nis2MetricSeverity) inNis2AreaMetricsModels.cs.
- Service interface
- Offline expectation: metrics are emitted from local observations only; no live regulator, SaaS, or external enrichment endpoint is required. A local, tenant-scoped read API exists (see Consumption) but performs no external calls.
Metric Naming
Metric names use:
nis2_area_<area>_<metric>_<unit>
Rules:
<area>is1through13, matchingdocs/europe/nis2-kpi-targets-defaults.yaml. (Note: the metric name uses the unpadded area number; the report/area id surfaces a zero-paddedarea01…area13viaNis2AreaMetricDefinition.AreaIdfor lexicographic sorting only.)<metric>is lowercase snake_case derived from the KPI key.<unit>is one ofpercent,count,days,minutes, orhours.- Each thematic area has 1 to 3 metrics in v1 to control cardinality.
Each gauge instrument also carries an OpenTelemetry unit string derived from the metric unit (Nis2AreaMetricDefinition.InstrumentUnit):
| Schema unit | OTel instrument unit |
|---|---|
percent | % |
count | {count} |
days | d |
minutes | min |
hours | h |
Labels
Every metric sample emits exactly these bounded labels:
| Label | Required | Notes |
|---|---|---|
tenant | yes | Tenant id as an opaque identifier. Must not contain customer names, emails, or human PII. |
control_id | yes | Stable NIS2 control id from the control register, for example NIS2-09-CRYPTOGRAPHY. |
severity | yes | ok, warning, critical, or unknown; derived from thresholds unless the producer supplies a reviewed override. |
Evidence refs are not metric labels. Producers may keep evidence refs in reports or evidence bundles, but they must not emit them as Prometheus labels.
Emission API
Telemetry Core owns the metric family and bounded in-memory reporting surface. Fact-owning modules emit samples by calling:
await nis2AreaMetrics.RecordMeasurementAsync(new Nis2AreaMetricMeasurement(
TenantId: tenantId,
ControlId: controlId,
MetricName: "nis2_area_9_deprecated_crypto_findings_count",
Value: deprecatedFindingCount,
ObservedAt: observedAt,
EvidenceRef: "evidence://cryptography/trust-profile/current",
Severity: null));
Nis2AreaMetricMeasurement fields (record positional parameters): TenantId, ControlId, MetricName, Value, ObservedAt, optional EvidenceRef (default null), and optional Severity (default null). On record, the service:
- Resolves the metric definition by
MetricName(unknown names throwArgumentException). - Trims
TenantId/ControlId(both required; blank throws) and normalizesObservedAtto UTC. - Sets the stored severity to the caller-supplied
Severityif present, otherwise todefinition.Classify(Value)— this is the “reviewed override” path referenced in the Labels table. - Blank
EvidenceRefis normalized tonull.
Recording a sample also emits a threshold-breach event through INis2EffectivenessThresholdBreachEmitter when the value crosses into warning/critical and the severity worsens relative to the previously recorded value for the same tenant/control/metric series. Hosts that only need the gauge surface and reports get a no-op emitter (NoOpNis2EffectivenessThresholdBreachEmitter); hosts wiring downstream notifications register a live emitter. When the caller supplies no EvidenceRef, the breach payload auto-generates a deterministic telemetry://nis2/effectiveness/<tenant>/<areaId>/<control>/<metricKey>/<observedAtUnixMs> reference.
Direct call-site adoption by Policy, Findings, Authority, and Crypto is downstream module work. Telemetry Core does not read another module’s persistence schema directly.
Threshold Semantics
targetMinmetrics classify values belowcriticalBelowascritical, values belowwarnBelowaswarning, and all other finite values asok.targetMaxmetrics classify values abovecriticalAboveascritical, values abovewarnAboveaswarning, and all other finite values asok.- Non-finite values are rejected by the emission API.
- Samples use the caller-provided
ObservedAttimestamp; Telemetry Core does not read wall-clock time to create measurements.
Catalog
| Area | Evidence mode | Metric key | Metric name | Unit | Target | Warning | Critical |
|---|---|---|---|---|---|---|---|
| 1 | mixed | policyCoveragePercent | nis2_area_1_policy_coverage_percent | percent | min 100 | below 100 | below 95 |
| 1 | mixed | overduePolicyReviews | nis2_area_1_overdue_policy_reviews_count | count | max 0 | above 0 | above 2 |
| 2 | mixed | riskAssessmentCurrencyDays | nis2_area_2_risk_assessment_currency_days | days | max 365 | above 365 | above 450 |
| 2 | mixed | highRiskTreatmentCoveragePercent | nis2_area_2_high_risk_treatment_coverage_percent | percent | min 95 | below 95 | below 90 |
| 3 | mixed | earlyWarningSloPercent | nis2_area_3_early_warning_slo_percent | percent | min 100 | below 100 | below 98 |
| 3 | mixed | incidentNotificationSloPercent | nis2_area_3_incident_notification_slo_percent | percent | min 100 | below 100 | below 98 |
| 3 | mixed | meanClassificationMinutes | nis2_area_3_mean_classification_minutes | minutes | max 60 | above 60 | above 240 |
| 4 | mixed | recoveryRunbookCurrencyDays | nis2_area_4_recovery_runbook_currency_days | days | max 365 | above 365 | above 450 |
| 4 | mixed | restoreTestSuccessPercent | nis2_area_4_restore_test_success_percent | percent | min 95 | below 95 | below 90 |
| 5 | stella-native | criticalSupplierAssessmentCoveragePercent | nis2_area_5_critical_supplier_assessment_coverage_percent | percent | min 95 | below 95 | below 90 |
| 5 | stella-native | criticalSupplierContractCoveragePercent | nis2_area_5_critical_supplier_contract_coverage_percent | percent | min 100 | below 100 | below 95 |
| 5 | stella-native | signedArtifactCoveragePercent | nis2_area_5_signed_artifact_coverage_percent | percent | min 100 | below 100 | below 98 |
| 6 | stella-native | releasePolicyGateCoveragePercent | nis2_area_6_release_policy_gate_coverage_percent | percent | min 100 | below 100 | below 98 |
| 6 | stella-native | criticalVulnerabilityTriageHours | nis2_area_6_critical_vulnerability_triage_hours | hours | max 24 | above 24 | above 48 |
| 6 | stella-native | highVulnerabilityRemediationDays | nis2_area_6_high_vulnerability_remediation_days | days | max 30 | above 30 | above 90 |
| 7 | mixed | controlTestCurrencyDays | nis2_area_7_control_test_currency_days | days | max 90 | above 90 | above 180 |
| 7 | mixed | evidenceFreshnessDays | nis2_area_7_evidence_freshness_days | days | max 30 | above 30 | above 90 |
| 8 | tenant-supplied | trainingAttestationCoveragePercent | nis2_area_8_training_attestation_coverage_percent | percent | min 95 | below 95 | below 90 |
| 8 | tenant-supplied | hygieneReviewCurrencyDays | nis2_area_8_hygiene_review_currency_days | days | max 365 | above 365 | above 450 |
| 9 | stella-native | deprecatedCryptoFindings | nis2_area_9_deprecated_crypto_findings_count | count | max 0 | above 0 | above 1 |
| 9 | stella-native | overdueKeyRotationsPercent | nis2_area_9_overdue_key_rotations_percent | percent | max 0 | above 0 | above 5 |
| 10 | tenant-supplied | privilegedAccessReviewCurrencyDays | nis2_area_10_privileged_access_review_currency_days | days | max 90 | above 90 | above 180 |
| 10 | tenant-supplied | joinerMoverLeaverCompletionHours | nis2_area_10_joiner_mover_leaver_completion_hours | hours | max 24 | above 24 | above 72 |
| 11 | mixed | privilegedMfaCoveragePercent | nis2_area_11_privileged_mfa_coverage_percent | percent | min 100 | below 100 | below 98 |
| 11 | mixed | stalePrivilegedAccounts | nis2_area_11_stale_privileged_accounts_count | count | max 0 | above 0 | above 1 |
| 11 | mixed | breakGlassTestCurrencyDays | nis2_area_11_break_glass_test_currency_days | days | max 180 | above 180 | above 365 |
| 12 | mixed | inventoryFreshnessMinutes | nis2_area_12_inventory_freshness_minutes | minutes | max 30 | above 30 | above 120 |
| 12 | mixed | criticalAssetOwnerCoveragePercent | nis2_area_12_critical_asset_owner_coverage_percent | percent | min 100 | below 100 | below 95 |
| 12 | mixed | unknownCriticalAssets | nis2_area_12_unknown_critical_assets_count | count | max 0 | above 0 | above 1 |
| 13 | tenant-supplied | physicalControlAttestationCurrencyDays | nis2_area_13_physical_control_attestation_currency_days | days | max 365 | above 365 | above 450 |
| 13 | tenant-supplied | datacenterProviderEvidenceCurrencyDays | nis2_area_13_datacenter_provider_evidence_currency_days | days | max 365 | above 365 | above 450 |
Replay And Offline Verification
- Reports are deterministic for the same tenant id, period start/end, and ordered sample set.
- Report areas are ordered
01through13; measurements inside an area are ordered by metric name and thencontrol_id. - Areas 8, 10, and 13 are tenant-supplied evidence areas in v1. Stella records opaque control/evidence refs only and must not become a customer HR, workforce-training, or physical-security system of record.
- Offline verification requires the local metric samples, the pinned catalog above, and the associated control-register snapshot.
Consumption
The recorded samples are surfaced to the Console/dashboard through a local, tenant-scoped read API on the Platform WebService (src/Platform/StellaOps.Platform.WebService/Endpoints/Nis2TelemetryEndpoints.cs). This is a read projection of the metric family above and emits no external calls.
- Route:
GET /api/telemetry/nis2/effectiveness - Authorization:
analytics.read(StellaOpsScopes.AnalyticsRead); request is tenant-scoped (RequireTenant). - Query parameters:
tenantId(optional; must equal the caller’s resolved tenant or the request is rejected withtenant_conflict) andwindow(rolling-30dorrolling-90d;default/blank resolve torolling-90d; other values return400 invalid_window). - Response schema id:
nis2-effectiveness-dashboard-v1(Nis2EffectivenessDashboardResponse) — a separate, richer contract that derives KPI labels, review cadences, area state, and linked controls from the catalog and the latest samples. It is NOT the metric schema defined here; see that contract for field-level detail. - The dashboard response currently advertises the
target-override-api-missingblocker and leavestargetDocumentHash/controlRegisterSnapshotHashnull until the override/audit contracts are connected. Monthly signed effectiveness export is available independently through the ExportCenter Assurance profile and CLI, so it is not a Platform telemetry blocker.
Security And Privacy
- Do not emit human names, emails, facility details, secrets, raw evidence refs, or vulnerability identifiers as metric labels.
- All three labels (
tenant,control_id, andseverity) are passed throughMetricLabelGuard.Coercewhen a guard is registered in the host. The guard is optional: when no guard is supplied the values are only trimmed. When present it truncates over-length values and replaces values exceeding the per-label distinct-value budget with the bucket labelother(cardinality protection). - Metric series are capped by
Nis2AreaMetricsOptions.MaxActiveSeries(default5000); exceeding the budget throwsInvalidOperationException. Report storage is bounded byMaxStoredTenants(default1000) andMaxStoredSamplesPerTenant(default10000); exceeding the tenant budget throws, and per-tenant samples beyond the cap are trimmed oldest-first.
