feat(telemetry): add bounded native pipeline lifecycle
This commit is contained in:
@@ -24,6 +24,8 @@ SOURCE_SCHEMAS: Final = {
|
||||
"heartbeat": "missioncore.agent-heartbeat/v1",
|
||||
}
|
||||
TELEGRAF_SCHEMA: Final = "telegraf.metric-json/v1"
|
||||
PIPELINE_RECORD_SCHEMA: Final = "missioncore.pipeline-telemetry-record/v1"
|
||||
PIPELINE_EVENT_MEASUREMENT: Final = "missioncore_pipeline_event"
|
||||
QUERY_SCHEMA: Final = "missioncore.telemetry-query/v1"
|
||||
SAFE_IDENTIFIER: Final = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$")
|
||||
MAX_PAYLOAD_BYTES: Final = 1024 * 1024
|
||||
@@ -43,6 +45,7 @@ ALLOWED_TELEMETRY_TAGS: Final = frozenset(
|
||||
"cpu",
|
||||
"device",
|
||||
"engine_host",
|
||||
"event_type",
|
||||
"gpu_name",
|
||||
"host",
|
||||
"interface",
|
||||
@@ -53,6 +56,7 @@ ALLOWED_TELEMETRY_TAGS: Final = frozenset(
|
||||
"path",
|
||||
"request_id",
|
||||
"run_id",
|
||||
"run_state",
|
||||
"server_version",
|
||||
"source_id",
|
||||
"source_package_id",
|
||||
@@ -135,6 +139,30 @@ def _sanitize_tags(document: dict[str, Any]) -> dict[str, Any]:
|
||||
return {**document, "tags": sanitized}
|
||||
|
||||
|
||||
def _unwrap_pipeline_event(
|
||||
topic: str,
|
||||
kind: str,
|
||||
document: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if kind != "pipeline" or document.get("name") != PIPELINE_EVENT_MEASUREMENT:
|
||||
return document
|
||||
fields = document.get("fields")
|
||||
if not isinstance(fields, dict) or not isinstance(fields.get("value"), str):
|
||||
raise ValueError("Telegraf pipeline event value is missing")
|
||||
record = json.loads(fields["value"])
|
||||
if (
|
||||
not isinstance(record, dict)
|
||||
or record.get("schema_version") != PIPELINE_RECORD_SCHEMA
|
||||
or record.get("topic") != topic
|
||||
or not isinstance(record.get("payload"), dict)
|
||||
):
|
||||
raise ValueError("pipeline event record is invalid")
|
||||
payload = record["payload"]
|
||||
if payload.get("schema_version") != SOURCE_SCHEMAS["pipeline"]:
|
||||
raise ValueError("pipeline event payload schema is invalid")
|
||||
return payload
|
||||
|
||||
|
||||
def _normalize(topic: str, payload: bytes) -> tuple[object, ...]:
|
||||
if len(payload) > MAX_PAYLOAD_BYTES:
|
||||
raise ValueError("telemetry payload exceeds the 1 MiB contract")
|
||||
@@ -144,8 +172,9 @@ def _normalize(topic: str, payload: bytes) -> tuple[object, ...]:
|
||||
document = json.loads(payload.decode("utf-8"))
|
||||
if not isinstance(document, dict):
|
||||
raise ValueError("payload must be an object")
|
||||
document = _sanitize_tags(document)
|
||||
kind = match.group("kind")
|
||||
document = _unwrap_pipeline_event(topic, kind, document)
|
||||
document = _sanitize_tags(document)
|
||||
source_schema = document.get("schema_version")
|
||||
if source_schema == SOURCE_SCHEMAS[kind]:
|
||||
observed_value = document.get("observed_at_utc")
|
||||
|
||||
@@ -3,7 +3,8 @@ param(
|
||||
[string]$Version = "1.38.4",
|
||||
[string]$ExpectedSha256 = "6c7878ec319471ac85b82443baec2f3fa5dbcf1b6e2da5d5cd2cbb60fff2bb45",
|
||||
[string]$ConfigurationTemplate = "$PSScriptRoot\mission-core-windows.conf.tmpl",
|
||||
[string]$PipelineCollector = "$PSScriptRoot\Get-NdcMissionCorePipelineTelemetry.ps1"
|
||||
[string]$PipelineCollector = "$PSScriptRoot\Get-NdcMissionCorePipelineTelemetry.ps1",
|
||||
[string]$PipelineJournal = "D:\NDC_MISSIONCORE\runtime\derived\.perception-persistent-publish\pipeline-telemetry.jsonl"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -41,6 +42,26 @@ if (-not (Test-Path -LiteralPath $ConfigurationTemplate -PathType Leaf)) {
|
||||
if (-not (Test-Path -LiteralPath $PipelineCollector -PathType Leaf)) {
|
||||
throw "Pipeline collector not found: $PipelineCollector"
|
||||
}
|
||||
$journalParent = Get-Item -LiteralPath (
|
||||
Resolve-Path -LiteralPath (Split-Path $PipelineJournal -Parent)
|
||||
).Path -Force
|
||||
if (
|
||||
-not $journalParent.PSIsContainer -or
|
||||
($journalParent.Attributes -band [IO.FileAttributes]::ReparsePoint) -or
|
||||
[IO.Path]::GetPathRoot($journalParent.FullName).TrimEnd("\") -ine "D:"
|
||||
) {
|
||||
throw "Pipeline journal parent must be a real D: directory"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $PipelineJournal)) {
|
||||
New-Item -ItemType File -Path $PipelineJournal | Out-Null
|
||||
}
|
||||
$journal = Get-Item -LiteralPath $PipelineJournal -Force
|
||||
if (
|
||||
$journal.PSIsContainer -or
|
||||
($journal.Attributes -band [IO.FileAttributes]::ReparsePoint)
|
||||
) {
|
||||
throw "Pipeline journal must be a regular file"
|
||||
}
|
||||
|
||||
$temporaryRoot = Join-Path $env:TEMP "ndc-mission-core-telegraf-$([Guid]::NewGuid().ToString('N'))"
|
||||
$archivePath = Join-Path $temporaryRoot "telegraf.zip"
|
||||
@@ -121,6 +142,7 @@ try {
|
||||
StartType = $service.StartType.ToString()
|
||||
Configuration = $configurationPath
|
||||
PipelineCollector = $collectorPath
|
||||
PipelineJournal = $PipelineJournal
|
||||
Sha256 = $actualSha256
|
||||
} | ConvertTo-Json -Compress
|
||||
}
|
||||
|
||||
@@ -6,14 +6,22 @@ param(
|
||||
[string]$ExpectedPredecessorSha256,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ExpectedCandidateSha256,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$PipelineTelemetryCandidate,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ExpectedPipelineTelemetrySha256,
|
||||
[string]$ExpectedPipelineTelemetryPredecessorSha256 = "absent",
|
||||
[string]$ContainerName = "ndc-mission-core-perception-worker",
|
||||
[string]$RunnerRoot = "D:\NDC_MISSIONCORE\runtime\derived\e23-runner-20260724-002"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$target = Join-Path $RunnerRoot "run_e15_shadow_inference.py"
|
||||
$telemetryTarget = Join-Path $RunnerRoot "pipeline_telemetry.py"
|
||||
$expectedPredecessor = $ExpectedPredecessorSha256.ToLowerInvariant()
|
||||
$expectedCandidate = $ExpectedCandidateSha256.ToLowerInvariant()
|
||||
$expectedTelemetry = $ExpectedPipelineTelemetrySha256.ToLowerInvariant()
|
||||
$expectedTelemetryPredecessor = $ExpectedPipelineTelemetryPredecessorSha256.ToLowerInvariant()
|
||||
$predecessor = (
|
||||
Get-FileHash -Algorithm SHA256 -LiteralPath $target
|
||||
).Hash.ToLowerInvariant()
|
||||
@@ -26,12 +34,34 @@ if (
|
||||
) {
|
||||
throw "Runner candidate digest changed"
|
||||
}
|
||||
$telemetryCandidateDigest = (
|
||||
Get-FileHash -Algorithm SHA256 -LiteralPath $PipelineTelemetryCandidate
|
||||
).Hash.ToLowerInvariant()
|
||||
if ($telemetryCandidateDigest -ne $expectedTelemetry) {
|
||||
throw "Pipeline telemetry candidate digest changed"
|
||||
}
|
||||
$hadTelemetryModule = Test-Path -LiteralPath $telemetryTarget -PathType Leaf
|
||||
$telemetryPredecessor = if ($hadTelemetryModule) {
|
||||
(Get-FileHash -Algorithm SHA256 -LiteralPath $telemetryTarget).Hash.ToLowerInvariant()
|
||||
}
|
||||
else {
|
||||
"absent"
|
||||
}
|
||||
if ($telemetryPredecessor -ne $expectedTelemetryPredecessor) {
|
||||
throw "Pipeline telemetry predecessor digest changed"
|
||||
}
|
||||
$backup = Join-Path $RunnerRoot (
|
||||
"run_e15_shadow_inference.py.rollback-" +
|
||||
[DateTime]::UtcNow.ToString("yyyyMMddTHHmmssZ") +
|
||||
"-" +
|
||||
$predecessor.Substring(0, 12)
|
||||
)
|
||||
$telemetryBackup = Join-Path $RunnerRoot (
|
||||
"pipeline_telemetry.py.rollback-" +
|
||||
[DateTime]::UtcNow.ToString("yyyyMMddTHHmmssZ") +
|
||||
"-" +
|
||||
$telemetryPredecessor.Substring(0, [Math]::Min(12, $telemetryPredecessor.Length))
|
||||
)
|
||||
$healthCode = "import urllib.request;print(urllib.request.urlopen('http://127.0.0.1:18020/health',timeout=2).read().decode())"
|
||||
|
||||
function Wait-PerceptionHealth {
|
||||
@@ -52,7 +82,11 @@ function Wait-PerceptionHealth {
|
||||
if (
|
||||
$health.ok -and
|
||||
$health.state -eq "ready" -and
|
||||
(-not $RequireStageMetrics -or $health.stage_metrics)
|
||||
(-not $RequireStageMetrics -or (
|
||||
$health.stage_metrics -and
|
||||
$health.native_pipeline_telemetry -and
|
||||
$health.native_pipeline_telemetry.ready
|
||||
))
|
||||
) {
|
||||
return $health
|
||||
}
|
||||
@@ -63,7 +97,12 @@ function Wait-PerceptionHealth {
|
||||
}
|
||||
|
||||
Copy-Item -LiteralPath $target -Destination $backup
|
||||
if ($hadTelemetryModule) {
|
||||
Copy-Item -LiteralPath $telemetryTarget -Destination $telemetryBackup
|
||||
}
|
||||
try {
|
||||
Copy-Item -LiteralPath $PipelineTelemetryCandidate `
|
||||
-Destination $telemetryTarget -Force
|
||||
Copy-Item -LiteralPath $Candidate -Destination $target -Force
|
||||
if (
|
||||
(Get-FileHash -Algorithm SHA256 -LiteralPath $target).Hash.ToLowerInvariant() `
|
||||
@@ -71,6 +110,12 @@ try {
|
||||
) {
|
||||
throw "Runner replacement digest changed"
|
||||
}
|
||||
if (
|
||||
(Get-FileHash -Algorithm SHA256 -LiteralPath $telemetryTarget).Hash.ToLowerInvariant() `
|
||||
-ne $expectedTelemetry
|
||||
) {
|
||||
throw "Pipeline telemetry replacement digest changed"
|
||||
}
|
||||
docker restart --time 20 $ContainerName | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Perception container restart failed"
|
||||
@@ -82,6 +127,12 @@ try {
|
||||
}
|
||||
catch {
|
||||
Copy-Item -LiteralPath $backup -Destination $target -Force
|
||||
if ($hadTelemetryModule) {
|
||||
Copy-Item -LiteralPath $telemetryBackup -Destination $telemetryTarget -Force
|
||||
}
|
||||
else {
|
||||
Remove-Item -LiteralPath $telemetryTarget -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
docker restart --time 20 $ContainerName | Out-Null
|
||||
$rollbackHealth = Wait-PerceptionHealth -RequireStageMetrics $false
|
||||
if (-not $rollbackHealth) {
|
||||
@@ -98,6 +149,11 @@ catch {
|
||||
CandidateSha256 = (
|
||||
Get-FileHash -Algorithm SHA256 -LiteralPath $target
|
||||
).Hash.ToLowerInvariant()
|
||||
PipelineTelemetryPredecessorSha256 = $telemetryPredecessor
|
||||
PipelineTelemetryCandidateSha256 = (
|
||||
Get-FileHash -Algorithm SHA256 -LiteralPath $telemetryTarget
|
||||
).Hash.ToLowerInvariant()
|
||||
PipelineTelemetryBackup = if ($hadTelemetryModule) { $telemetryBackup } else { $null }
|
||||
Backup = $backup
|
||||
Health = $health
|
||||
} | ConvertTo-Json -Depth 12 -Compress
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Candidate,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ExpectedPredecessorSha256,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ExpectedCandidateSha256,
|
||||
[string]$RunnerRoot = "D:\NDC_MISSIONCORE\runtime\derived\e23-runner-20260724-002"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$target = Join-Path $RunnerRoot "Invoke-E15PersistentShadowRun.ps1"
|
||||
$expectedPredecessor = $ExpectedPredecessorSha256.ToLowerInvariant()
|
||||
$expectedCandidate = $ExpectedCandidateSha256.ToLowerInvariant()
|
||||
$predecessor = (
|
||||
Get-FileHash -Algorithm SHA256 -LiteralPath $target
|
||||
).Hash.ToLowerInvariant()
|
||||
if ($predecessor -ne $expectedPredecessor) {
|
||||
throw "Persistent launcher predecessor digest changed"
|
||||
}
|
||||
if (
|
||||
(Get-FileHash -Algorithm SHA256 -LiteralPath $Candidate).Hash.ToLowerInvariant() `
|
||||
-ne $expectedCandidate
|
||||
) {
|
||||
throw "Persistent launcher candidate digest changed"
|
||||
}
|
||||
$backup = Join-Path $RunnerRoot (
|
||||
"Invoke-E15PersistentShadowRun.ps1.rollback-" +
|
||||
[DateTime]::UtcNow.ToString("yyyyMMddTHHmmssZ") +
|
||||
"-" +
|
||||
$predecessor.Substring(0, 12)
|
||||
)
|
||||
|
||||
Copy-Item -LiteralPath $target -Destination $backup
|
||||
try {
|
||||
Copy-Item -LiteralPath $Candidate -Destination $target -Force
|
||||
$installed = (
|
||||
Get-FileHash -Algorithm SHA256 -LiteralPath $target
|
||||
).Hash.ToLowerInvariant()
|
||||
if ($installed -ne $expectedCandidate) {
|
||||
throw "Persistent launcher replacement digest changed"
|
||||
}
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
[Management.Automation.Language.Parser]::ParseFile(
|
||||
$target,
|
||||
[ref]$tokens,
|
||||
[ref]$parseErrors
|
||||
) | Out-Null
|
||||
if (@($parseErrors).Count -ne 0) {
|
||||
throw "Persistent launcher candidate has PowerShell parse errors"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Copy-Item -LiteralPath $backup -Destination $target -Force
|
||||
throw
|
||||
}
|
||||
|
||||
[ordered]@{
|
||||
SchemaVersion = "missioncore.worker-persistent-launcher-deploy-result/v1"
|
||||
PredecessorSha256 = $predecessor
|
||||
CandidateSha256 = (
|
||||
Get-FileHash -Algorithm SHA256 -LiteralPath $target
|
||||
).Hash.ToLowerInvariant()
|
||||
Backup = $backup
|
||||
} | ConvertTo-Json -Compress
|
||||
@@ -2,7 +2,8 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ConfigurationTemplate,
|
||||
[string]$PipelineCollector = "$PSScriptRoot\Get-NdcMissionCorePipelineTelemetry.ps1"
|
||||
[string]$PipelineCollector = "$PSScriptRoot\Get-NdcMissionCorePipelineTelemetry.ps1",
|
||||
[string]$PipelineJournal = "D:\NDC_MISSIONCORE\runtime\derived\.perception-persistent-publish\pipeline-telemetry.jsonl"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -24,6 +25,28 @@ if (-not (Test-Path -LiteralPath $ConfigurationTemplate -PathType Leaf)) {
|
||||
if (-not (Test-Path -LiteralPath $PipelineCollector -PathType Leaf)) {
|
||||
throw "Pipeline collector not found: $PipelineCollector"
|
||||
}
|
||||
$journalParent = Get-Item -LiteralPath (
|
||||
Resolve-Path -LiteralPath (Split-Path $PipelineJournal -Parent)
|
||||
).Path -Force
|
||||
if (
|
||||
-not $journalParent.PSIsContainer -or
|
||||
($journalParent.Attributes -band [IO.FileAttributes]::ReparsePoint) -or
|
||||
[IO.Path]::GetPathRoot($journalParent.FullName).TrimEnd("\") -ine "D:"
|
||||
) {
|
||||
throw "Pipeline journal parent must be a real D: directory"
|
||||
}
|
||||
if (Test-Path -LiteralPath $PipelineJournal) {
|
||||
$journal = Get-Item -LiteralPath $PipelineJournal -Force
|
||||
if (
|
||||
$journal.PSIsContainer -or
|
||||
($journal.Attributes -band [IO.FileAttributes]::ReparsePoint)
|
||||
) {
|
||||
throw "Pipeline journal must be a regular file"
|
||||
}
|
||||
}
|
||||
else {
|
||||
New-Item -ItemType File -Path $PipelineJournal | Out-Null
|
||||
}
|
||||
|
||||
$serviceEnvironmentBefore = @((Get-ItemProperty -Path $serviceRegistryPath).Environment)
|
||||
foreach ($entry in $serviceEnvironmentBefore) {
|
||||
@@ -116,6 +139,7 @@ try {
|
||||
Backup = $backupPath
|
||||
PipelineCollector = $collectorPath
|
||||
PipelineCollectorBackup = if ($hadCollector) { $backupCollectorPath } else { $null }
|
||||
PipelineJournal = $PipelineJournal
|
||||
} | ConvertTo-Json -Compress
|
||||
}
|
||||
catch {
|
||||
|
||||
@@ -61,6 +61,17 @@
|
||||
"collector_state",
|
||||
]
|
||||
|
||||
[[inputs.tail]]
|
||||
files = ["D:\\NDC_MISSIONCORE\\runtime\\derived\\.perception-persistent-publish\\pipeline-telemetry.jsonl"]
|
||||
initial_read_offset = "saved-or-beginning"
|
||||
watch_method = "poll"
|
||||
max_undelivered_lines = 1000
|
||||
character_encoding = "utf-8"
|
||||
path_tag = ""
|
||||
name_override = "missioncore_pipeline_event"
|
||||
data_format = "value"
|
||||
data_type = "string"
|
||||
|
||||
[[outputs.mqtt]]
|
||||
servers = ["tcp://${MISSIONCORE_MQTT_HOST}:${MISSIONCORE_MQTT_PORT}"]
|
||||
topic = "mission-core/v1/contours/${MISSIONCORE_CONTOUR_ID}/agents/${MISSIONCORE_AGENT_ID}/host"
|
||||
@@ -69,7 +80,7 @@
|
||||
qos = 1
|
||||
data_format = "json"
|
||||
json_timestamp_units = "1s"
|
||||
namedrop = ["missioncore_pipeline"]
|
||||
namedrop = ["missioncore_pipeline", "missioncore_pipeline_event"]
|
||||
|
||||
[[outputs.mqtt]]
|
||||
servers = ["tcp://${MISSIONCORE_MQTT_HOST}:${MISSIONCORE_MQTT_PORT}"]
|
||||
@@ -79,4 +90,4 @@
|
||||
qos = 1
|
||||
data_format = "json"
|
||||
json_timestamp_units = "1s"
|
||||
namepass = ["missioncore_pipeline"]
|
||||
namepass = ["missioncore_pipeline", "missioncore_pipeline_event"]
|
||||
|
||||
@@ -7,7 +7,13 @@ param(
|
||||
[string]$PersistentOutputRoot = "D:\NDC_MISSIONCORE\runtime\derived\.perception-persistent-publish",
|
||||
[ValidateRange(1024, 65535)] [int]$PersistentPort = 18020,
|
||||
[ValidateRange(5, 3600)] [int]$MaximumDurationSeconds = 20,
|
||||
[ValidateRange(1, 1000)] [int]$FreeGiBFloor = 360
|
||||
[ValidateRange(1, 1000)] [int]$FreeGiBFloor = 360,
|
||||
[ValidatePattern("^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$")]
|
||||
[string]$ContourId = "worker-006",
|
||||
[ValidatePattern("^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$")]
|
||||
[string]$AgentId = "worker-006",
|
||||
[ValidatePattern("^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")]
|
||||
[string]$NodeId = "DESKTOP-OPJ8J04"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -55,6 +61,11 @@ $request = @{
|
||||
output_name = $outputName
|
||||
token = $token
|
||||
max_duration_seconds = $MaximumDurationSeconds
|
||||
telemetry = @{
|
||||
contour_id = $ContourId
|
||||
agent_id = $AgentId
|
||||
node_id = $NodeId
|
||||
}
|
||||
} | ConvertTo-Json -Compress
|
||||
$token = $null
|
||||
$client = (
|
||||
|
||||
@@ -22,7 +22,7 @@ import time
|
||||
from collections import Counter, deque
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager, suppress
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -106,6 +106,21 @@ from k1link.compute.live_perception import (
|
||||
from k1link.data_plane import DecodedPointCloudView, DecodedPoseView
|
||||
from k1link.device_plugins.xgrids_k1.protocol.normalizer import normalize_k1_message
|
||||
|
||||
try:
|
||||
from k1link.compute.pipeline_telemetry import (
|
||||
JsonlPipelineTelemetrySink,
|
||||
PipelineTelemetryEmitter,
|
||||
PipelineTelemetryIdentity,
|
||||
)
|
||||
except ModuleNotFoundError as exc:
|
||||
if exc.name != "k1link.compute.pipeline_telemetry":
|
||||
raise
|
||||
from pipeline_telemetry import ( # type: ignore[no-redef]
|
||||
JsonlPipelineTelemetrySink,
|
||||
PipelineTelemetryEmitter,
|
||||
PipelineTelemetryIdentity,
|
||||
)
|
||||
|
||||
PROFILE_SCHEMA = "missioncore.e15-shadow-inference-profile/v1"
|
||||
PROJECTION_SCHEMA = "missioncore.e15-live-projection-pack/v1"
|
||||
WORKER_PACKAGE_SCHEMA = "missioncore.e15-worker-package/v1"
|
||||
@@ -790,19 +805,71 @@ class _RuntimeTelemetry:
|
||||
return summary
|
||||
|
||||
|
||||
class _FailureIsolatingPipelineTelemetrySink:
|
||||
"""Keep telemetry failures visible without changing compute control flow."""
|
||||
|
||||
def __init__(self, sink: Any) -> None:
|
||||
self._sink = sink
|
||||
self._lock = threading.Lock()
|
||||
self._publish_failures = 0
|
||||
self._last_error_type: str | None = None
|
||||
|
||||
def publish(self, topic: str, payload: bytes) -> None:
|
||||
try:
|
||||
self._sink.publish(topic, payload)
|
||||
except Exception as exc:
|
||||
with self._lock:
|
||||
self._publish_failures += 1
|
||||
self._last_error_type = type(exc).__name__
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "pipeline-telemetry-publish-failed",
|
||||
"error_type": type(exc).__name__,
|
||||
},
|
||||
sort_keys=True,
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
"transport": "jsonl-tail",
|
||||
"ready": self._publish_failures == 0,
|
||||
"publish_failures": self._publish_failures,
|
||||
"last_error_type": self._last_error_type,
|
||||
}
|
||||
|
||||
|
||||
class _StageExecutionTelemetry:
|
||||
"""Measure named pipeline spans without pretending they are OS processes."""
|
||||
|
||||
def __init__(self, stage_ids: tuple[str, ...] = PIPELINE_STAGE_IDS) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
stage_ids: tuple[str, ...] = PIPELINE_STAGE_IDS,
|
||||
*,
|
||||
identity: PipelineTelemetryIdentity | None = None,
|
||||
sink: _FailureIsolatingPipelineTelemetrySink | None = None,
|
||||
) -> None:
|
||||
if not stage_ids or len(stage_ids) != len(set(stage_ids)):
|
||||
raise RuntimeError("pipeline stage identities are invalid")
|
||||
if (identity is None) != (sink is None):
|
||||
raise RuntimeError("pipeline telemetry identity and sink must be paired")
|
||||
self._stage_ids = stage_ids
|
||||
self._identity = identity
|
||||
self._sink = sink
|
||||
self._lock = threading.Lock()
|
||||
self._next_token = 0
|
||||
self._active: dict[int, tuple[str, float, int | None]] = {}
|
||||
self._elapsed_seconds = dict.fromkeys(stage_ids, 0.0)
|
||||
self._activations = dict.fromkeys(stage_ids, 0)
|
||||
self._last_frame_index: int | None = None
|
||||
self._first_frame_by_stage: dict[str, int | None] = {}
|
||||
self._last_frame_by_stage: dict[str, int | None] = {}
|
||||
self._native_started: set[str] = set()
|
||||
self._native_failed: set[str] = set()
|
||||
self._native_finalized = False
|
||||
|
||||
@contextmanager
|
||||
def measure(
|
||||
@@ -813,21 +880,116 @@ class _StageExecutionTelemetry:
|
||||
if stage_id not in self._elapsed_seconds:
|
||||
raise RuntimeError(f"unknown pipeline stage: {stage_id}")
|
||||
started = time.perf_counter()
|
||||
emit_started = False
|
||||
with self._lock:
|
||||
self._next_token += 1
|
||||
token = self._next_token
|
||||
self._active[token] = (stage_id, started, frame_index)
|
||||
self._activations[stage_id] += 1
|
||||
self._last_frame_by_stage[stage_id] = frame_index
|
||||
if stage_id not in self._first_frame_by_stage:
|
||||
self._first_frame_by_stage[stage_id] = frame_index
|
||||
if self.native_events_enabled and stage_id not in self._native_started:
|
||||
self._native_started.add(stage_id)
|
||||
emit_started = True
|
||||
if frame_index is not None:
|
||||
self._last_frame_index = frame_index
|
||||
if emit_started:
|
||||
self._emit_native(
|
||||
stage_id=stage_id,
|
||||
state="started",
|
||||
frame_index=frame_index,
|
||||
activation_count=1,
|
||||
)
|
||||
failure: BaseException | None = None
|
||||
try:
|
||||
yield
|
||||
except BaseException as exc:
|
||||
failure = exc
|
||||
raise
|
||||
finally:
|
||||
finished = time.perf_counter()
|
||||
failure_event: tuple[float, int, int | None] | None = None
|
||||
with self._lock:
|
||||
active = self._active.pop(token, None)
|
||||
if active is not None:
|
||||
self._elapsed_seconds[stage_id] += max(0.0, finished - active[1])
|
||||
if (
|
||||
failure is not None
|
||||
and self.native_events_enabled
|
||||
and stage_id not in self._native_failed
|
||||
):
|
||||
self._native_failed.add(stage_id)
|
||||
failure_event = (
|
||||
self._elapsed_seconds[stage_id],
|
||||
self._activations[stage_id],
|
||||
self._last_frame_by_stage.get(stage_id),
|
||||
)
|
||||
if failure_event is not None:
|
||||
elapsed_seconds, activations, last_frame_index = failure_event
|
||||
self._emit_native(
|
||||
stage_id=stage_id,
|
||||
state="failed",
|
||||
frame_index=last_frame_index,
|
||||
duration_ms=elapsed_seconds * 1000,
|
||||
activation_count=activations,
|
||||
error_type=type(failure).__name__,
|
||||
)
|
||||
|
||||
@property
|
||||
def native_events_enabled(self) -> bool:
|
||||
return self._identity is not None and self._sink is not None
|
||||
|
||||
def finalize_native(self) -> None:
|
||||
"""Emit one aggregate terminal event for each stage used by the run."""
|
||||
|
||||
rows: list[tuple[str, float, int, int | None]] = []
|
||||
with self._lock:
|
||||
if not self.native_events_enabled or self._native_finalized:
|
||||
return
|
||||
self._native_finalized = True
|
||||
rows = [
|
||||
(
|
||||
stage_id,
|
||||
self._elapsed_seconds[stage_id],
|
||||
self._activations[stage_id],
|
||||
self._last_frame_by_stage.get(stage_id),
|
||||
)
|
||||
for stage_id in self._stage_ids
|
||||
if stage_id in self._native_started
|
||||
and stage_id not in self._native_failed
|
||||
]
|
||||
for stage_id, elapsed_seconds, activations, frame_index in rows:
|
||||
self._emit_native(
|
||||
stage_id=stage_id,
|
||||
state="completed",
|
||||
frame_index=frame_index,
|
||||
duration_ms=elapsed_seconds * 1000,
|
||||
activation_count=activations,
|
||||
)
|
||||
|
||||
def _emit_native(
|
||||
self,
|
||||
*,
|
||||
stage_id: str,
|
||||
state: str,
|
||||
frame_index: int | None,
|
||||
activation_count: int,
|
||||
duration_ms: float | None = None,
|
||||
error_type: str | None = None,
|
||||
) -> None:
|
||||
if self._identity is None or self._sink is None:
|
||||
return
|
||||
PipelineTelemetryEmitter(
|
||||
identity=replace(self._identity, frame_index=frame_index),
|
||||
sink=self._sink,
|
||||
).stage_event(
|
||||
stage_id,
|
||||
state,
|
||||
duration_ms=duration_ms,
|
||||
activation_count=activation_count,
|
||||
error_type=error_type,
|
||||
)
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
now = time.perf_counter()
|
||||
@@ -2172,6 +2334,44 @@ def _persistent_run_arguments(
|
||||
return argparse.Namespace(**values)
|
||||
|
||||
|
||||
def _persistent_run_telemetry_identity(
|
||||
common: dict[str, Any],
|
||||
request: dict[str, Any],
|
||||
) -> PipelineTelemetryIdentity | None:
|
||||
telemetry = request.get("telemetry")
|
||||
if telemetry is None:
|
||||
return None
|
||||
if not isinstance(telemetry, dict) or set(telemetry) != {
|
||||
"contour_id",
|
||||
"agent_id",
|
||||
"node_id",
|
||||
}:
|
||||
raise RuntimeError("persistent worker telemetry identity is invalid")
|
||||
request_id = request.get("request_id")
|
||||
if not isinstance(request_id, str):
|
||||
raise RuntimeError("persistent worker telemetry request identity is invalid")
|
||||
stability = common.get("stability")
|
||||
lab_id = (
|
||||
stability["profile_id"]
|
||||
if isinstance(stability, dict) and isinstance(stability.get("profile_id"), str)
|
||||
else "lab-e15-shadow-inference-v1"
|
||||
)
|
||||
method_id = (
|
||||
INLINE_TEMPORAL_PIPELINE_ID if isinstance(stability, dict) else PIPELINE_ID
|
||||
)
|
||||
return PipelineTelemetryIdentity(
|
||||
contour_id=telemetry.get("contour_id"),
|
||||
agent_id=telemetry.get("agent_id"),
|
||||
node_id=telemetry.get("node_id"),
|
||||
lab_id=lab_id,
|
||||
run_id=request_id,
|
||||
request_id=request_id,
|
||||
source_id=common["live"]["source"]["source_id"],
|
||||
source_package_id=common["worker_package"]["package_id"],
|
||||
method_id=method_id,
|
||||
)
|
||||
|
||||
|
||||
def serve(args: argparse.Namespace) -> int:
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
@@ -2183,6 +2383,9 @@ def serve(args: argparse.Namespace) -> int:
|
||||
raise RuntimeError("LAB E15 PyAV version changed")
|
||||
output_root = args.output_root.resolve(strict=True)
|
||||
_assert_disk(output_root, args.free_bytes_floor)
|
||||
telemetry_sink = _FailureIsolatingPipelineTelemetrySink(
|
||||
JsonlPipelineTelemetrySink(output_root / "pipeline-telemetry.jsonl")
|
||||
)
|
||||
load_started = time.perf_counter()
|
||||
loaded = _load_models(args, common)
|
||||
model_load_seconds = time.perf_counter() - load_started
|
||||
@@ -2193,6 +2396,7 @@ def serve(args: argparse.Namespace) -> int:
|
||||
"failed_runs": 0,
|
||||
"active_request_id": None,
|
||||
"active_frame_index": None,
|
||||
"last_run_outcome": None,
|
||||
"_stage_telemetry": _StageExecutionTelemetry(),
|
||||
}
|
||||
|
||||
@@ -2237,6 +2441,13 @@ def serve(args: argparse.Namespace) -> int:
|
||||
"current_stage": stage_snapshot["current_stage"],
|
||||
"active_stages": stage_snapshot["active_stages"],
|
||||
"stage_metrics": stage_snapshot["stages"],
|
||||
"last_run_outcome": state["last_run_outcome"],
|
||||
"native_pipeline_telemetry": {
|
||||
**telemetry_sink.status(),
|
||||
"active_run_events_enabled": (
|
||||
state["_stage_telemetry"].native_events_enabled
|
||||
),
|
||||
},
|
||||
"authority": common["live"]["authority"],
|
||||
"gpu": torch.cuda.get_device_name(),
|
||||
},
|
||||
@@ -2259,16 +2470,51 @@ def serve(args: argparse.Namespace) -> int:
|
||||
return
|
||||
state["busy"] = True
|
||||
request_id = "invalid"
|
||||
run_identity: PipelineTelemetryIdentity | None = None
|
||||
run_emitter: PipelineTelemetryEmitter | None = None
|
||||
run_started: float | None = None
|
||||
terminal_event_emitted = False
|
||||
try:
|
||||
document = json.loads(self.rfile.read(length))
|
||||
if not isinstance(document, dict):
|
||||
raise RuntimeError("persistent worker request is not an object")
|
||||
request_id = str(document.get("request_id", "invalid"))
|
||||
run_args = _persistent_run_arguments(args, document)
|
||||
run_identity = _persistent_run_telemetry_identity(common, document)
|
||||
document["token"] = None
|
||||
state["active_request_id"] = request_id
|
||||
state["_stage_telemetry"] = _StageExecutionTelemetry()
|
||||
state["_stage_telemetry"] = _StageExecutionTelemetry(
|
||||
identity=run_identity,
|
||||
sink=telemetry_sink if run_identity is not None else None,
|
||||
)
|
||||
run_emitter = (
|
||||
PipelineTelemetryEmitter(
|
||||
identity=run_identity,
|
||||
sink=telemetry_sink,
|
||||
)
|
||||
if run_identity is not None
|
||||
else None
|
||||
)
|
||||
run_started = time.perf_counter()
|
||||
if run_emitter is not None:
|
||||
run_emitter.run("started")
|
||||
exit_code = run(run_args, loaded, state)
|
||||
state["_stage_telemetry"].finalize_native()
|
||||
duration_ms = max(0.0, (time.perf_counter() - run_started) * 1000)
|
||||
if run_emitter is not None:
|
||||
run_emitter.run(
|
||||
"completed",
|
||||
duration_ms=duration_ms,
|
||||
exit_code=exit_code,
|
||||
)
|
||||
terminal_event_emitted = True
|
||||
state["last_run_outcome"] = {
|
||||
"request_id": request_id,
|
||||
"state": "completed",
|
||||
"duration_ms": round(duration_ms, 6),
|
||||
"exit_code": exit_code,
|
||||
"error_type": None,
|
||||
}
|
||||
state["completed_runs"] += 1
|
||||
self._send(
|
||||
200,
|
||||
@@ -2280,6 +2526,31 @@ def serve(args: argparse.Namespace) -> int:
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
state["_stage_telemetry"].finalize_native()
|
||||
duration_ms = (
|
||||
max(0.0, (time.perf_counter() - run_started) * 1000)
|
||||
if run_started is not None
|
||||
else None
|
||||
)
|
||||
if (
|
||||
run_emitter is not None
|
||||
and duration_ms is not None
|
||||
and not terminal_event_emitted
|
||||
):
|
||||
run_emitter.run(
|
||||
"failed",
|
||||
duration_ms=duration_ms,
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
state["last_run_outcome"] = {
|
||||
"request_id": request_id,
|
||||
"state": "failed",
|
||||
"duration_ms": (
|
||||
round(duration_ms, 6) if duration_ms is not None else None
|
||||
),
|
||||
"exit_code": None,
|
||||
"error_type": type(exc).__name__,
|
||||
}
|
||||
state["failed_runs"] += 1
|
||||
print(
|
||||
json.dumps(
|
||||
|
||||
@@ -31,6 +31,7 @@ SAFE_TOPIC_IDENTIFIER: Final = re.compile(
|
||||
MAX_TEXT_LENGTH: Final = 256
|
||||
MAX_PAYLOAD_BYTES: Final = 1024 * 1024
|
||||
STAGE_STATES: Final = frozenset({"started", "completed", "failed"})
|
||||
RUN_STATES: Final = STAGE_STATES
|
||||
_AUTHORITY: Final = {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
@@ -162,6 +163,52 @@ class PipelineTelemetryEmitter:
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
|
||||
def run(
|
||||
self,
|
||||
state: str,
|
||||
*,
|
||||
duration_ms: float | None = None,
|
||||
exit_code: int | None = None,
|
||||
error_type: str | None = None,
|
||||
) -> None:
|
||||
"""Publish one run-level lifecycle event with an explicit outcome."""
|
||||
|
||||
document = build_pipeline_run_telemetry_document(
|
||||
identity=self.identity,
|
||||
state=state,
|
||||
duration_ms=duration_ms,
|
||||
exit_code=exit_code,
|
||||
error_type=error_type,
|
||||
)
|
||||
self._publish(document)
|
||||
|
||||
def stage_event(
|
||||
self,
|
||||
stage_id: str,
|
||||
state: str,
|
||||
*,
|
||||
duration_ms: float | None = None,
|
||||
activation_count: int = 1,
|
||||
input_count: int | None = None,
|
||||
output_count: int | None = None,
|
||||
queue_wait_ms: float | None = None,
|
||||
error_type: str | None = None,
|
||||
) -> None:
|
||||
"""Publish one explicit stage event, including a per-run aggregate."""
|
||||
|
||||
document = build_pipeline_telemetry_document(
|
||||
identity=self.identity,
|
||||
stage_id=stage_id,
|
||||
state=state,
|
||||
duration_ms=duration_ms,
|
||||
activation_count=activation_count,
|
||||
input_count=input_count,
|
||||
output_count=output_count,
|
||||
queue_wait_ms=queue_wait_ms,
|
||||
error_type=error_type,
|
||||
)
|
||||
self._publish(document)
|
||||
|
||||
def _emit(
|
||||
self,
|
||||
*,
|
||||
@@ -177,16 +224,18 @@ class PipelineTelemetryEmitter:
|
||||
outcome.queue_wait_ms,
|
||||
"queue_wait_ms",
|
||||
)
|
||||
document = build_pipeline_telemetry_document(
|
||||
identity=self.identity,
|
||||
self.stage_event(
|
||||
stage_id=stage_id,
|
||||
state=state,
|
||||
duration_ms=duration_ms,
|
||||
activation_count=1,
|
||||
input_count=outcome.input_count,
|
||||
output_count=outcome.output_count,
|
||||
queue_wait_ms=outcome.queue_wait_ms,
|
||||
error_type=error_type,
|
||||
)
|
||||
|
||||
def _publish(self, document: dict[str, Any]) -> None:
|
||||
payload = _canonical_json(document)
|
||||
if len(payload) > MAX_PAYLOAD_BYTES:
|
||||
raise PipelineTelemetryError("pipeline telemetry exceeds the 1 MiB contract")
|
||||
@@ -245,6 +294,7 @@ def build_pipeline_telemetry_document(
|
||||
stage_id: str,
|
||||
state: str,
|
||||
duration_ms: float | None = None,
|
||||
activation_count: int = 1,
|
||||
input_count: int | None = None,
|
||||
output_count: int | None = None,
|
||||
queue_wait_ms: float | None = None,
|
||||
@@ -257,6 +307,9 @@ def build_pipeline_telemetry_document(
|
||||
if state not in STAGE_STATES:
|
||||
raise PipelineTelemetryError("stage telemetry state is invalid")
|
||||
duration_ms = _optional_duration(duration_ms, "duration_ms")
|
||||
activation_count = _optional_count(activation_count, "activation_count")
|
||||
if activation_count in {None, 0}:
|
||||
raise PipelineTelemetryError("activation_count must be positive")
|
||||
input_count = _optional_count(input_count, "input_count")
|
||||
output_count = _optional_count(output_count, "output_count")
|
||||
queue_wait_ms = _optional_duration(queue_wait_ms, "queue_wait_ms")
|
||||
@@ -289,7 +342,7 @@ def build_pipeline_telemetry_document(
|
||||
"elapsed_seconds": (
|
||||
round(duration_ms / 1000.0, 9) if duration_ms is not None else None
|
||||
),
|
||||
"activations": 1,
|
||||
"activations": activation_count,
|
||||
"input_count": input_count,
|
||||
"output_count": output_count,
|
||||
"queue_wait_ms": queue_wait_ms,
|
||||
@@ -298,6 +351,7 @@ def build_pipeline_telemetry_document(
|
||||
"stage_id": stage_id,
|
||||
"state": state,
|
||||
"duration_ms": duration_ms,
|
||||
"activation_count": activation_count,
|
||||
"input_count": input_count,
|
||||
"output_count": output_count,
|
||||
"queue_wait_ms": queue_wait_ms,
|
||||
@@ -336,6 +390,92 @@ def build_pipeline_telemetry_document(
|
||||
return document
|
||||
|
||||
|
||||
def build_pipeline_run_telemetry_document(
|
||||
*,
|
||||
identity: PipelineTelemetryIdentity,
|
||||
state: str,
|
||||
duration_ms: float | None = None,
|
||||
exit_code: int | None = None,
|
||||
error_type: str | None = None,
|
||||
observed_at_utc: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a run-level start or terminal outcome document."""
|
||||
|
||||
if state not in RUN_STATES:
|
||||
raise PipelineTelemetryError("run telemetry state is invalid")
|
||||
duration_ms = _optional_duration(duration_ms, "duration_ms")
|
||||
if state == "started":
|
||||
if duration_ms is not None or exit_code is not None or error_type is not None:
|
||||
raise PipelineTelemetryError("a started run cannot have an outcome")
|
||||
elif duration_ms is None:
|
||||
raise PipelineTelemetryError("a terminal run requires duration_ms")
|
||||
if exit_code is not None and (
|
||||
isinstance(exit_code, bool) or not isinstance(exit_code, int) or exit_code < 0
|
||||
):
|
||||
raise PipelineTelemetryError("exit_code must be a non-negative integer")
|
||||
if state == "completed" and exit_code is None:
|
||||
raise PipelineTelemetryError("a completed run requires exit_code")
|
||||
if error_type is not None:
|
||||
_validate_text(error_type, "error_type")
|
||||
if state == "failed" and error_type is None:
|
||||
raise PipelineTelemetryError("a failed run requires error_type")
|
||||
if state != "failed" and error_type is not None:
|
||||
raise PipelineTelemetryError("only a failed run can have error_type")
|
||||
|
||||
tags = {
|
||||
"agent_id": identity.agent_id,
|
||||
"contour_id": identity.contour_id,
|
||||
"event_type": "run",
|
||||
"lab_id": identity.lab_id,
|
||||
"method_id": identity.method_id,
|
||||
"node_id": identity.node_id,
|
||||
"run_id": identity.run_id,
|
||||
"run_state": state,
|
||||
"source_id": identity.source_id,
|
||||
"source_package_id": identity.source_package_id,
|
||||
}
|
||||
if identity.request_id is not None:
|
||||
tags["request_id"] = identity.request_id
|
||||
document: dict[str, Any] = {
|
||||
"schema_version": PIPELINE_TELEMETRY_SCHEMA,
|
||||
"observed_at_utc": observed_at_utc or _utc_now(),
|
||||
"node_id": identity.node_id,
|
||||
"lab_id": identity.lab_id,
|
||||
"run_id": identity.run_id,
|
||||
"source_id": identity.source_id,
|
||||
"source_package_id": identity.source_package_id,
|
||||
"method_id": identity.method_id,
|
||||
"event_type": "run",
|
||||
"run_state": state,
|
||||
"tags": tags,
|
||||
"payload": {
|
||||
"state": (
|
||||
"busy"
|
||||
if state == "started"
|
||||
else ("failed" if state == "failed" else "ready")
|
||||
),
|
||||
"current_stage": None,
|
||||
"active_request_id": (
|
||||
identity.request_id or identity.run_id
|
||||
if state == "started"
|
||||
else None
|
||||
),
|
||||
"active_stages": [],
|
||||
"event": {
|
||||
"event_type": "run",
|
||||
"state": state,
|
||||
"duration_ms": duration_ms,
|
||||
"exit_code": exit_code,
|
||||
"error_type": error_type,
|
||||
},
|
||||
},
|
||||
"authority": _AUTHORITY,
|
||||
}
|
||||
if identity.request_id is not None:
|
||||
document["request_id"] = identity.request_id
|
||||
return document
|
||||
|
||||
|
||||
def _validate_text(value: object, name: str) -> str:
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
|
||||
@@ -75,6 +75,7 @@ PROMETHEUS_SAMPLE = re.compile(
|
||||
r"(?:\{[^}]*\})?\s+(?P<value>[0-9.eE+-]+)$"
|
||||
)
|
||||
TELEMETRY_QUERY_SCHEMA: Final = "missioncore.telemetry-query/v1"
|
||||
PIPELINE_TELEMETRY_SCHEMA: Final = "missioncore.agent-pipeline-telemetry/v1"
|
||||
DEFAULT_TELEMETRY_QUERY_URL: Final = "http://127.0.0.1:18030"
|
||||
|
||||
RootProvider = Callable[[], Path]
|
||||
@@ -510,6 +511,12 @@ def _agent_raw_document(document: dict[str, Any]) -> dict[str, Any]:
|
||||
if model_load_seconds is not None:
|
||||
perception["model_load_seconds"] = model_load_seconds
|
||||
perception["collector_state"] = fields.get("collector_state")
|
||||
elif sample.get("source_schema") == PIPELINE_TELEMETRY_SCHEMA:
|
||||
# Native lifecycle rows are immutable run evidence. The current
|
||||
# service snapshot remains owned by the periodic health sample,
|
||||
# so a terminal event cannot make a healthy worker look idle,
|
||||
# failed, or busy after the fact.
|
||||
continue
|
||||
else:
|
||||
pipeline_payload = _mapping(payload.get("payload")) or payload
|
||||
for name, value in pipeline_payload.items():
|
||||
|
||||
@@ -371,6 +371,46 @@ def test_persistent_worker_request_is_single_run_d_backed_and_token_bounded(
|
||||
)
|
||||
|
||||
|
||||
def test_persistent_worker_derives_native_telemetry_from_accepted_runtime() -> None:
|
||||
module = _module()
|
||||
common = {
|
||||
"live": {"source": {"source_id": "sensor.camera.right"}},
|
||||
"stability": {"profile_id": "lab-e23-warm-worker-inline-temporal-v1"},
|
||||
"worker_package": {"package_id": "e15-worker-package-example"},
|
||||
}
|
||||
request = {
|
||||
"request_id": "physical-k1-shadow-001",
|
||||
"telemetry": {
|
||||
"contour_id": "worker-006",
|
||||
"agent_id": "worker-006",
|
||||
"node_id": "DESKTOP-OPJ8J04",
|
||||
},
|
||||
}
|
||||
|
||||
identity = module._persistent_run_telemetry_identity(common, request)
|
||||
|
||||
assert identity is not None
|
||||
assert identity.lab_id == "lab-e23-warm-worker-inline-temporal-v1"
|
||||
assert identity.run_id == "physical-k1-shadow-001"
|
||||
assert identity.request_id == "physical-k1-shadow-001"
|
||||
assert identity.source_id == "sensor.camera.right"
|
||||
assert identity.source_package_id == "e15-worker-package-example"
|
||||
assert identity.method_id == "warm-worker-inline-bounded-temporal-2d-3d-semantic/v1"
|
||||
assert module._persistent_run_telemetry_identity(
|
||||
common,
|
||||
{"request_id": "legacy-request"},
|
||||
) is None
|
||||
|
||||
with pytest.raises(RuntimeError, match="telemetry identity"):
|
||||
module._persistent_run_telemetry_identity(
|
||||
common,
|
||||
{
|
||||
"request_id": "invalid-telemetry",
|
||||
"telemetry": {"contour_id": "worker-006"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_telemetry_captures_bounded_queue_snapshots() -> None:
|
||||
module = _module()
|
||||
stream = io.StringIO()
|
||||
@@ -422,3 +462,44 @@ def test_stage_execution_telemetry_measures_named_spans_without_process_claims()
|
||||
telemetry.measure("unregistered"),
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
def test_stage_execution_telemetry_writes_native_frame_lifecycle() -> None:
|
||||
module = _module()
|
||||
published: list[dict[str, object]] = []
|
||||
|
||||
class Sink:
|
||||
def publish(self, _topic: str, payload: bytes) -> None:
|
||||
published.append(json.loads(payload))
|
||||
|
||||
identity = module.PipelineTelemetryIdentity(
|
||||
contour_id="worker-006",
|
||||
agent_id="worker-006",
|
||||
node_id="DESKTOP-OPJ8J04",
|
||||
lab_id="lab-e23-warm-worker-inline-temporal-v1",
|
||||
run_id="run-001",
|
||||
request_id="run-001",
|
||||
source_id="sensor.camera.right",
|
||||
source_package_id="e15-worker-package-example",
|
||||
method_id="warm-worker-inline-bounded-temporal-2d-3d-semantic/v1",
|
||||
)
|
||||
telemetry = module._StageExecutionTelemetry(
|
||||
("detector",),
|
||||
identity=identity,
|
||||
sink=module._FailureIsolatingPipelineTelemetrySink(Sink()),
|
||||
)
|
||||
|
||||
with telemetry.measure("detector", frame_index=42):
|
||||
pass
|
||||
with telemetry.measure("detector", frame_index=43):
|
||||
pass
|
||||
|
||||
assert telemetry.native_events_enabled is True
|
||||
assert [row["stage_state"] for row in published] == ["started"]
|
||||
telemetry.finalize_native()
|
||||
assert [row["stage_state"] for row in published] == ["started", "completed"]
|
||||
assert [row["frame_index"] for row in published] == [42, 43]
|
||||
assert published[-1]["payload"]["event"]["activation_count"] == 2
|
||||
|
||||
telemetry.finalize_native()
|
||||
assert len(published) == 2
|
||||
|
||||
@@ -13,6 +13,7 @@ from k1link.compute.pipeline_telemetry import (
|
||||
PipelineTelemetryEmitter,
|
||||
PipelineTelemetryError,
|
||||
PipelineTelemetryIdentity,
|
||||
build_pipeline_run_telemetry_document,
|
||||
build_pipeline_telemetry_document,
|
||||
)
|
||||
|
||||
@@ -59,6 +60,7 @@ def test_pipeline_document_is_accepted_without_losing_stage_identity() -> None:
|
||||
stage_id="predict",
|
||||
state="completed",
|
||||
duration_ms=125.5,
|
||||
activation_count=7,
|
||||
input_count=89,
|
||||
output_count=89,
|
||||
queue_wait_ms=2.25,
|
||||
@@ -89,9 +91,51 @@ def test_pipeline_document_is_accepted_without_losing_stage_identity() -> None:
|
||||
}
|
||||
stored = json.loads(row[13])
|
||||
assert stored["payload"]["event"]["duration_ms"] == 125.5
|
||||
assert stored["payload"]["event"]["activation_count"] == 7
|
||||
assert stored["payload"]["stage_metrics"]["predict"]["activations"] == 7
|
||||
assert stored["authority"]["commands_enabled"] is False
|
||||
|
||||
|
||||
def test_telegraf_tail_wrapper_restores_the_native_pipeline_document() -> None:
|
||||
identity = _identity()
|
||||
native = build_pipeline_telemetry_document(
|
||||
identity=identity,
|
||||
stage_id="tracking",
|
||||
state="completed",
|
||||
duration_ms=4.25,
|
||||
observed_at_utc="2026-07-28T12:00:00Z",
|
||||
)
|
||||
record = {
|
||||
"schema_version": "missioncore.pipeline-telemetry-record/v1",
|
||||
"topic": identity.topic,
|
||||
"payload": native,
|
||||
}
|
||||
telegraf = {
|
||||
"name": "missioncore_pipeline_event",
|
||||
"timestamp": 1785240000,
|
||||
"tags": {
|
||||
"agent_id": identity.agent_id,
|
||||
"contour_id": identity.contour_id,
|
||||
"node_id": identity.node_id,
|
||||
},
|
||||
"fields": {
|
||||
"value": json.dumps(record, separators=(",", ":")),
|
||||
},
|
||||
}
|
||||
|
||||
row = _normalizer()._normalize(identity.topic, json.dumps(telegraf).encode())
|
||||
|
||||
assert row[5] == "pipeline"
|
||||
assert row[7] == "missioncore.agent-pipeline-telemetry/v1"
|
||||
assert row[9:13] == ("E41", "run-001", "request-001", 17)
|
||||
assert json.loads(row[13]) == native
|
||||
|
||||
record["topic"] = "mission-core/v1/contours/other/agents/other/pipeline"
|
||||
telegraf["fields"]["value"] = json.dumps(record)
|
||||
with pytest.raises(ValueError, match="pipeline event record"):
|
||||
_normalizer()._normalize(identity.topic, json.dumps(telegraf).encode())
|
||||
|
||||
|
||||
def test_stage_context_emits_terminal_event_and_preserves_failure() -> None:
|
||||
published: list[tuple[str, dict[str, object]]] = []
|
||||
|
||||
@@ -131,6 +175,42 @@ def test_stage_context_emits_terminal_event_and_preserves_failure() -> None:
|
||||
assert "source failure" not in json.dumps(published[-1][1])
|
||||
|
||||
|
||||
def test_run_events_record_explicit_terminal_outcome() -> None:
|
||||
identity = _identity()
|
||||
started = build_pipeline_run_telemetry_document(
|
||||
identity=identity,
|
||||
state="started",
|
||||
observed_at_utc="2026-07-28T12:00:00Z",
|
||||
)
|
||||
completed = build_pipeline_run_telemetry_document(
|
||||
identity=identity,
|
||||
state="completed",
|
||||
duration_ms=12_345.5,
|
||||
exit_code=2,
|
||||
observed_at_utc="2026-07-28T12:00:12Z",
|
||||
)
|
||||
|
||||
assert started["payload"]["state"] == "busy"
|
||||
assert started["payload"]["active_request_id"] == "request-001"
|
||||
assert completed["event_type"] == "run"
|
||||
assert completed["run_state"] == "completed"
|
||||
assert completed["payload"]["active_request_id"] is None
|
||||
assert completed["payload"]["event"] == {
|
||||
"event_type": "run",
|
||||
"state": "completed",
|
||||
"duration_ms": 12_345.5,
|
||||
"exit_code": 2,
|
||||
"error_type": None,
|
||||
}
|
||||
|
||||
with pytest.raises(PipelineTelemetryError, match="completed run"):
|
||||
build_pipeline_run_telemetry_document(
|
||||
identity=identity,
|
||||
state="completed",
|
||||
duration_ms=1.0,
|
||||
)
|
||||
|
||||
|
||||
def test_jsonl_sink_records_topic_bound_documents(tmp_path: Path) -> None:
|
||||
path = tmp_path / "telemetry" / "e41.jsonl"
|
||||
identity = _identity()
|
||||
|
||||
@@ -306,6 +306,24 @@ def test_agent_pipeline_snapshot_merges_all_stage_series() -> None:
|
||||
},
|
||||
}
|
||||
)
|
||||
samples.append(
|
||||
{
|
||||
"node_id": EXPECTED_NODE_ID,
|
||||
"kind": "pipeline",
|
||||
"measurement": "pipeline",
|
||||
"source_schema": "missioncore.agent-pipeline-telemetry/v1",
|
||||
"observed_at_utc": "2026-07-28T12:00:01Z",
|
||||
"payload": {
|
||||
"schema_version": "missioncore.agent-pipeline-telemetry/v1",
|
||||
"payload": {
|
||||
"state": "failed",
|
||||
"current_stage": None,
|
||||
"active_request_id": None,
|
||||
"active_stages": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
raw = _agent_raw_document({"samples": samples})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user