Feat/posthog error tracking redaction (#329)
* feat(telemetry): send renderer exceptions to PostHog error tracking * fix(telemetry): redact local paths and urls before capture * feat(telemetry): enrich backend error metadata * fix(telemetry): document telemetry helper exports * fix(telemetry): satisfy gocritic on helper signature * chore: format with prettier [skip ci] * fix(telemetry): redact embedded local urls * chore: format with prettier [skip ci] --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
34a9fdb736
commit
0e1c5fe54b
|
|
@ -35,26 +35,37 @@ var remotePayloadAllowlist = map[string]map[string]struct{}{
|
||||||
"command_path": {},
|
"command_path": {},
|
||||||
},
|
},
|
||||||
"ao.cli.usage_errors": {
|
"ao.cli.usage_errors": {
|
||||||
|
"component": {},
|
||||||
"command": {},
|
"command": {},
|
||||||
"command_path": {},
|
"command_path": {},
|
||||||
"error_kind": {},
|
"error_kind": {},
|
||||||
|
"fingerprint": {},
|
||||||
|
"operation": {},
|
||||||
},
|
},
|
||||||
"ao.daemon.panic": {
|
"ao.daemon.panic": {
|
||||||
"method": {},
|
"component": {},
|
||||||
"path": {},
|
"fingerprint": {},
|
||||||
"panic_kind": {},
|
"method": {},
|
||||||
|
"operation": {},
|
||||||
|
"path": {},
|
||||||
|
"panic_kind": {},
|
||||||
|
"stack_fingerprint": {},
|
||||||
},
|
},
|
||||||
"ao.daemon.started": {
|
"ao.daemon.started": {
|
||||||
"agent": {},
|
"agent": {},
|
||||||
"port": {},
|
"port": {},
|
||||||
},
|
},
|
||||||
"ao.http.5xx": {
|
"ao.http.5xx": {
|
||||||
"duration": {},
|
"component": {},
|
||||||
"error_code": {},
|
"duration": {},
|
||||||
"error_kind": {},
|
"error_code": {},
|
||||||
"method": {},
|
"error_kind": {},
|
||||||
"path": {},
|
"fingerprint": {},
|
||||||
"status": {},
|
"method": {},
|
||||||
|
"operation": {},
|
||||||
|
"path": {},
|
||||||
|
"status": {},
|
||||||
|
"status_family": {},
|
||||||
},
|
},
|
||||||
"ao.onboarding.first_project_added": {
|
"ao.onboarding.first_project_added": {
|
||||||
"has_git_remote": {},
|
"has_git_remote": {},
|
||||||
|
|
@ -70,11 +81,14 @@ var remotePayloadAllowlist = map[string]map[string]struct{}{
|
||||||
"kind": {},
|
"kind": {},
|
||||||
},
|
},
|
||||||
"ao.session.spawn_failed": {
|
"ao.session.spawn_failed": {
|
||||||
|
"component": {},
|
||||||
"duration_ms": {},
|
"duration_ms": {},
|
||||||
"error_code": {},
|
"error_code": {},
|
||||||
"error_kind": {},
|
"error_kind": {},
|
||||||
|
"fingerprint": {},
|
||||||
"harness": {},
|
"harness": {},
|
||||||
"kind": {},
|
"kind": {},
|
||||||
|
"operation": {},
|
||||||
},
|
},
|
||||||
"ao.session.spawned": {
|
"ao.session.spawned": {
|
||||||
"duration_ms": {},
|
"duration_ms": {},
|
||||||
|
|
|
||||||
|
|
@ -93,11 +93,15 @@ func TestPostHogSinkSanitizesPayloads(t *testing.T) {
|
||||||
OccurredAt: time.Unix(1700000000, 0).UTC(),
|
OccurredAt: time.Unix(1700000000, 0).UTC(),
|
||||||
Level: ports.TelemetryLevelError,
|
Level: ports.TelemetryLevelError,
|
||||||
Payload: map[string]any{
|
Payload: map[string]any{
|
||||||
"method": http.MethodGet,
|
"component": "httpd",
|
||||||
"path": "/api/v1/sessions/demo",
|
"operation": "http_request_panic",
|
||||||
"panic_kind": "error",
|
"method": http.MethodGet,
|
||||||
"panic": "open /Users/name/private: no such file",
|
"path": "/api/v1/sessions/demo",
|
||||||
"stack": "stack trace with local path",
|
"panic_kind": "error",
|
||||||
|
"fingerprint": "abc123",
|
||||||
|
"stack_fingerprint": "def456",
|
||||||
|
"panic": "open /Users/name/private: no such file",
|
||||||
|
"stack": "stack trace with local path",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err := sink.Close(context.Background()); err != nil {
|
if err := sink.Close(context.Background()); err != nil {
|
||||||
|
|
@ -110,9 +114,15 @@ func TestPostHogSinkSanitizesPayloads(t *testing.T) {
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("properties type = %T, want map[string]any", req["properties"])
|
t.Fatalf("properties type = %T, want map[string]any", req["properties"])
|
||||||
}
|
}
|
||||||
|
if props["component"] != "httpd" || props["operation"] != "http_request_panic" {
|
||||||
|
t.Fatalf("sanitized properties = %#v, want allowlisted metadata", props)
|
||||||
|
}
|
||||||
if props["method"] != http.MethodGet || props["path"] != "/api/v1/sessions/demo" || props["panic_kind"] != "error" {
|
if props["method"] != http.MethodGet || props["path"] != "/api/v1/sessions/demo" || props["panic_kind"] != "error" {
|
||||||
t.Fatalf("sanitized properties = %#v, want allowlisted fields", props)
|
t.Fatalf("sanitized properties = %#v, want allowlisted fields", props)
|
||||||
}
|
}
|
||||||
|
if props["fingerprint"] != "abc123" || props["stack_fingerprint"] != "def456" {
|
||||||
|
t.Fatalf("sanitized properties = %#v, want exported fingerprints", props)
|
||||||
|
}
|
||||||
if _, ok := props["panic"]; ok {
|
if _, ok := props["panic"]; ok {
|
||||||
t.Fatalf("panic property should be dropped: %#v", props)
|
t.Fatalf("panic property should be dropped: %#v", props)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
package httpd
|
package httpd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr"
|
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
|
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||||
|
"github.com/aoagents/agent-orchestrator/backend/internal/telemetrymeta"
|
||||||
)
|
)
|
||||||
|
|
||||||
// requestLogger emits one structured access-log line per request via the
|
// requestLogger emits one structured access-log line per request via the
|
||||||
|
|
@ -48,21 +48,23 @@ func requestLogger(log *slog.Logger, sink ports.EventSink) func(http.Handler) ht
|
||||||
}
|
}
|
||||||
log.Info("http request", attrs...)
|
log.Info("http request", attrs...)
|
||||||
if sink != nil && ww.Status() >= http.StatusInternalServerError {
|
if sink != nil && ww.Status() >= http.StatusInternalServerError {
|
||||||
|
path := telemetrymeta.RoutePattern(r)
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"method": r.Method,
|
"component": "httpd",
|
||||||
"path": r.URL.Path,
|
"operation": "http_request",
|
||||||
"status": ww.Status(),
|
"method": r.Method,
|
||||||
"duration": time.Since(start).Milliseconds(),
|
"path": path,
|
||||||
|
"status": ww.Status(),
|
||||||
|
"status_family": telemetrymeta.StatusFamily(ww.Status()),
|
||||||
|
"duration": time.Since(start).Milliseconds(),
|
||||||
}
|
}
|
||||||
if err := capturedErr(); err != nil {
|
if err := capturedErr(); err != nil {
|
||||||
payload["error_kind"] = "internal"
|
errorKind, errorCode := telemetrymeta.ErrorKindAndCode(err)
|
||||||
var apiErr *apierr.Error
|
payload["error_kind"] = errorKind
|
||||||
if errors.As(err, &apiErr) {
|
if errorCode != "" {
|
||||||
payload["error_kind"] = telemetryErrorKind(apiErr.Kind)
|
payload["error_code"] = errorCode
|
||||||
if apiErr.Code != "" {
|
|
||||||
payload["error_code"] = apiErr.Code
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
payload["fingerprint"] = telemetrymeta.Fingerprint("httpd", "http_request", r.Method, path, strconv.Itoa(ww.Status()), errorKind, errorCode)
|
||||||
}
|
}
|
||||||
sink.Emit(r.Context(), ports.TelemetryEvent{
|
sink.Emit(r.Context(), ports.TelemetryEvent{
|
||||||
Name: "ao.http.5xx",
|
Name: "ao.http.5xx",
|
||||||
|
|
@ -78,16 +80,3 @@ func requestLogger(log *slog.Logger, sink ports.EventSink) func(http.Handler) ht
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func telemetryErrorKind(kind apierr.Kind) string {
|
|
||||||
switch kind {
|
|
||||||
case apierr.KindInvalid:
|
|
||||||
return "invalid"
|
|
||||||
case apierr.KindNotFound:
|
|
||||||
return "not_found"
|
|
||||||
case apierr.KindConflict:
|
|
||||||
return "conflict"
|
|
||||||
default:
|
|
||||||
return "internal"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,31 @@ func TestRequestLoggerRecords5xxCause(t *testing.T) {
|
||||||
if len(sink.events) != 1 || sink.events[0].Name != "ao.http.5xx" {
|
if len(sink.events) != 1 || sink.events[0].Name != "ao.http.5xx" {
|
||||||
t.Fatalf("telemetry events = %#v, want one ao.http.5xx event", sink.events)
|
t.Fatalf("telemetry events = %#v, want one ao.http.5xx event", sink.events)
|
||||||
}
|
}
|
||||||
|
payload := sink.events[0].Payload
|
||||||
|
if got := payload["component"]; got != "httpd" {
|
||||||
|
t.Fatalf("payload.component = %#v, want httpd", got)
|
||||||
|
}
|
||||||
|
if got := payload["operation"]; got != "http_request" {
|
||||||
|
t.Fatalf("payload.operation = %#v, want http_request", got)
|
||||||
|
}
|
||||||
|
if got := payload["method"]; got != http.MethodPost {
|
||||||
|
t.Fatalf("payload.method = %#v, want POST", got)
|
||||||
|
}
|
||||||
|
if got := payload["path"]; got != "/api/v1/sessions/x/kill" {
|
||||||
|
t.Fatalf("payload.path = %#v, want request path fallback", got)
|
||||||
|
}
|
||||||
|
if got := payload["status"]; got != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("payload.status = %#v, want 500", got)
|
||||||
|
}
|
||||||
|
if got := payload["status_family"]; got != "5xx" {
|
||||||
|
t.Fatalf("payload.status_family = %#v, want 5xx", got)
|
||||||
|
}
|
||||||
|
if got := payload["error_kind"]; got != "internal" {
|
||||||
|
t.Fatalf("payload.error_kind = %#v, want internal", got)
|
||||||
|
}
|
||||||
|
if got := payload["fingerprint"]; got == "" {
|
||||||
|
t.Fatalf("payload.fingerprint = %#v, want non-empty", got)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
|
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
|
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||||
|
"github.com/aoagents/agent-orchestrator/backend/internal/telemetrymeta"
|
||||||
)
|
)
|
||||||
|
|
||||||
func recoverTelemetry(log *slog.Logger, sink ports.EventSink) func(http.Handler) http.Handler {
|
func recoverTelemetry(log *slog.Logger, sink ports.EventSink) func(http.Handler) http.Handler {
|
||||||
|
|
@ -29,6 +30,8 @@ func recoverTelemetry(log *slog.Logger, sink ports.EventSink) func(http.Handler)
|
||||||
"stack", stack,
|
"stack", stack,
|
||||||
)
|
)
|
||||||
if sink != nil {
|
if sink != nil {
|
||||||
|
path := telemetrymeta.RoutePattern(r)
|
||||||
|
panicKind := telemetrymeta.PanicKind(rec)
|
||||||
sink.Emit(r.Context(), ports.TelemetryEvent{
|
sink.Emit(r.Context(), ports.TelemetryEvent{
|
||||||
Name: "ao.daemon.panic",
|
Name: "ao.daemon.panic",
|
||||||
Source: "http",
|
Source: "http",
|
||||||
|
|
@ -36,9 +39,13 @@ func recoverTelemetry(log *slog.Logger, sink ports.EventSink) func(http.Handler)
|
||||||
Level: ports.TelemetryLevelError,
|
Level: ports.TelemetryLevelError,
|
||||||
RequestID: middleware.GetReqID(r.Context()),
|
RequestID: middleware.GetReqID(r.Context()),
|
||||||
Payload: map[string]any{
|
Payload: map[string]any{
|
||||||
"method": r.Method,
|
"component": "httpd",
|
||||||
"path": r.URL.Path,
|
"operation": "http_request_panic",
|
||||||
"panic_kind": telemetryPanicKind(rec),
|
"method": r.Method,
|
||||||
|
"path": path,
|
||||||
|
"panic_kind": panicKind,
|
||||||
|
"stack_fingerprint": telemetrymeta.Fingerprint("httpd", "http_request_panic", path, panicKind, stack),
|
||||||
|
"fingerprint": telemetrymeta.Fingerprint("httpd", "http_request_panic", r.Method, path, panicKind),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -50,17 +57,6 @@ func recoverTelemetry(log *slog.Logger, sink ports.EventSink) func(http.Handler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func telemetryPanicKind(rec any) string {
|
|
||||||
switch rec.(type) {
|
|
||||||
case error:
|
|
||||||
return "error"
|
|
||||||
case string:
|
|
||||||
return "string"
|
|
||||||
default:
|
|
||||||
return "other"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeRecoveredError(w http.ResponseWriter, r *http.Request) {
|
func writeRecoveredError(w http.ResponseWriter, r *http.Request) {
|
||||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal_error", "INTERNAL_ERROR", "Internal server error", nil)
|
envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal_error", "INTERNAL_ERROR", "Internal server error", nil)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import (
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/daemonmeta"
|
"github.com/aoagents/agent-orchestrator/backend/internal/daemonmeta"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
|
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||||
|
"github.com/aoagents/agent-orchestrator/backend/internal/telemetrymeta"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/terminal"
|
"github.com/aoagents/agent-orchestrator/backend/internal/terminal"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -187,9 +188,12 @@ func mountTelemetry(r chi.Router, sink ports.EventSink) {
|
||||||
Level: ports.TelemetryLevelWarn,
|
Level: ports.TelemetryLevelWarn,
|
||||||
RequestID: middleware.GetReqID(req.Context()),
|
RequestID: middleware.GetReqID(req.Context()),
|
||||||
Payload: map[string]any{
|
Payload: map[string]any{
|
||||||
|
"component": "cli",
|
||||||
|
"operation": "command_parse",
|
||||||
"command": body.Command,
|
"command": body.Command,
|
||||||
"command_path": body.CommandPath,
|
"command_path": body.CommandPath,
|
||||||
"error_kind": "usage",
|
"error_kind": "usage",
|
||||||
|
"fingerprint": telemetrymeta.Fingerprint("cli", "command_parse", body.CommandPath, "usage"),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
w.WriteHeader(http.StatusAccepted)
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,25 @@ func TestCLIUsageErrorRouteEmitsTelemetry(t *testing.T) {
|
||||||
if len(sink.events) != 1 || sink.events[0].Name != "ao.cli.usage_errors" {
|
if len(sink.events) != 1 || sink.events[0].Name != "ao.cli.usage_errors" {
|
||||||
t.Fatalf("events = %#v, want one ao.cli.usage_errors event", sink.events)
|
t.Fatalf("events = %#v, want one ao.cli.usage_errors event", sink.events)
|
||||||
}
|
}
|
||||||
|
payload := sink.events[0].Payload
|
||||||
|
if got := payload["component"]; got != "cli" {
|
||||||
|
t.Fatalf("payload.component = %#v, want cli", got)
|
||||||
|
}
|
||||||
|
if got := payload["operation"]; got != "command_parse" {
|
||||||
|
t.Fatalf("payload.operation = %#v, want command_parse", got)
|
||||||
|
}
|
||||||
|
if got := payload["command_path"]; got != "ao status" {
|
||||||
|
t.Fatalf("payload.command_path = %#v, want ao status", got)
|
||||||
|
}
|
||||||
|
if got := payload["error_kind"]; got != "usage" {
|
||||||
|
t.Fatalf("payload.error_kind = %#v, want usage", got)
|
||||||
|
}
|
||||||
|
if got := payload["fingerprint"]; got == "" {
|
||||||
|
t.Fatalf("payload.fingerprint = %#v, want non-empty", got)
|
||||||
|
}
|
||||||
|
if _, ok := payload["error"]; ok {
|
||||||
|
t.Fatalf("payload leaked raw error text: %#v", payload)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRecoverTelemetryEmitsPanicEvent(t *testing.T) {
|
func TestRecoverTelemetryEmitsPanicEvent(t *testing.T) {
|
||||||
|
|
@ -90,19 +109,43 @@ func TestRecoverTelemetryEmitsPanicEvent(t *testing.T) {
|
||||||
if rec.Code != http.StatusInternalServerError {
|
if rec.Code != http.StatusInternalServerError {
|
||||||
t.Fatalf("status = %d, want 500", rec.Code)
|
t.Fatalf("status = %d, want 500", rec.Code)
|
||||||
}
|
}
|
||||||
var sawPanic, saw5xx bool
|
var panicPayload, fiveXXPayload map[string]any
|
||||||
for _, ev := range sink.events {
|
for _, ev := range sink.events {
|
||||||
switch ev.Name {
|
switch ev.Name {
|
||||||
case "ao.daemon.panic":
|
case "ao.daemon.panic":
|
||||||
sawPanic = true
|
panicPayload = ev.Payload
|
||||||
case "ao.http.5xx":
|
case "ao.http.5xx":
|
||||||
saw5xx = true
|
fiveXXPayload = ev.Payload
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !sawPanic {
|
if panicPayload == nil {
|
||||||
t.Fatalf("events = %#v, want ao.daemon.panic", sink.events)
|
t.Fatalf("events = %#v, want ao.daemon.panic", sink.events)
|
||||||
}
|
}
|
||||||
if !saw5xx {
|
if fiveXXPayload == nil {
|
||||||
t.Fatalf("events = %#v, want ao.http.5xx after recovery", sink.events)
|
t.Fatalf("events = %#v, want ao.http.5xx after recovery", sink.events)
|
||||||
}
|
}
|
||||||
|
if got := panicPayload["component"]; got != "httpd" {
|
||||||
|
t.Fatalf("panic payload.component = %#v, want httpd", got)
|
||||||
|
}
|
||||||
|
if got := panicPayload["operation"]; got != "http_request_panic" {
|
||||||
|
t.Fatalf("panic payload.operation = %#v, want http_request_panic", got)
|
||||||
|
}
|
||||||
|
if got := panicPayload["path"]; got != "/panic" {
|
||||||
|
t.Fatalf("panic payload.path = %#v, want /panic", got)
|
||||||
|
}
|
||||||
|
if got := panicPayload["panic_kind"]; got != "string" {
|
||||||
|
t.Fatalf("panic payload.panic_kind = %#v, want string", got)
|
||||||
|
}
|
||||||
|
if got := panicPayload["fingerprint"]; got == "" {
|
||||||
|
t.Fatalf("panic payload.fingerprint = %#v, want non-empty", got)
|
||||||
|
}
|
||||||
|
if got := panicPayload["stack_fingerprint"]; got == "" {
|
||||||
|
t.Fatalf("panic payload.stack_fingerprint = %#v, want non-empty", got)
|
||||||
|
}
|
||||||
|
if got := fiveXXPayload["path"]; got != "/panic" {
|
||||||
|
t.Fatalf("5xx payload.path = %#v, want /panic", got)
|
||||||
|
}
|
||||||
|
if got := fiveXXPayload["status_family"]; got != "5xx" {
|
||||||
|
t.Fatalf("5xx payload.status_family = %#v, want 5xx", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr"
|
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||||
sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager"
|
sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager"
|
||||||
|
"github.com/aoagents/agent-orchestrator/backend/internal/telemetrymeta"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Store is the read-only persistence surface needed to assemble controller-facing session read models.
|
// Store is the read-only persistence surface needed to assemble controller-facing session read models.
|
||||||
|
|
@ -229,18 +230,15 @@ func (s *Service) emitSpawnFailed(cfg ports.SpawnConfig, err error, durationMs i
|
||||||
}
|
}
|
||||||
projectID := cfg.ProjectID
|
projectID := cfg.ProjectID
|
||||||
apiErr := toAPIError(err)
|
apiErr := toAPIError(err)
|
||||||
errorKind := "internal"
|
errorKind, errorCode := telemetrymeta.ErrorKindAndCode(apiErr)
|
||||||
errorCode := ""
|
|
||||||
var typedErr *apierr.Error
|
|
||||||
if errors.As(apiErr, &typedErr) {
|
|
||||||
errorKind = telemetryErrorKind(typedErr.Kind)
|
|
||||||
errorCode = typedErr.Code
|
|
||||||
}
|
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
|
"component": "session_service",
|
||||||
|
"operation": "spawn_session",
|
||||||
"kind": string(cfg.Kind),
|
"kind": string(cfg.Kind),
|
||||||
"harness": string(cfg.Harness),
|
"harness": string(cfg.Harness),
|
||||||
"duration_ms": durationMs,
|
"duration_ms": durationMs,
|
||||||
"error_kind": errorKind,
|
"error_kind": errorKind,
|
||||||
|
"fingerprint": telemetrymeta.Fingerprint("session_service", "spawn_session", string(cfg.Kind), string(cfg.Harness), errorKind, errorCode),
|
||||||
}
|
}
|
||||||
if errorCode != "" {
|
if errorCode != "" {
|
||||||
payload["error_code"] = errorCode
|
payload["error_code"] = errorCode
|
||||||
|
|
@ -255,19 +253,6 @@ func (s *Service) emitSpawnFailed(cfg ports.SpawnConfig, err error, durationMs i
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func telemetryErrorKind(kind apierr.Kind) string {
|
|
||||||
switch kind {
|
|
||||||
case apierr.KindInvalid:
|
|
||||||
return "invalid"
|
|
||||||
case apierr.KindNotFound:
|
|
||||||
return "not_found"
|
|
||||||
case apierr.KindConflict:
|
|
||||||
return "conflict"
|
|
||||||
default:
|
|
||||||
return "internal"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SpawnOrchestrator spawns an orchestrator session for a project. When clean is
|
// SpawnOrchestrator spawns an orchestrator session for a project. When clean is
|
||||||
// true it first tears down any active orchestrator(s) for that project so the new
|
// true it first tears down any active orchestrator(s) for that project so the new
|
||||||
// one is the only live coordinator — a business rule that belongs here, not in the
|
// one is the only live coordinator — a business rule that belongs here, not in the
|
||||||
|
|
|
||||||
|
|
@ -337,6 +337,15 @@ func TestSpawnFailedEmitsDuration(t *testing.T) {
|
||||||
if got := sink.events[0].Payload["error_kind"]; got != "internal" {
|
if got := sink.events[0].Payload["error_kind"]; got != "internal" {
|
||||||
t.Fatalf("spawn_failed error_kind = %#v, want internal", got)
|
t.Fatalf("spawn_failed error_kind = %#v, want internal", got)
|
||||||
}
|
}
|
||||||
|
if got := sink.events[0].Payload["component"]; got != "session_service" {
|
||||||
|
t.Fatalf("spawn_failed component = %#v, want session_service", got)
|
||||||
|
}
|
||||||
|
if got := sink.events[0].Payload["operation"]; got != "spawn_session" {
|
||||||
|
t.Fatalf("spawn_failed operation = %#v, want spawn_session", got)
|
||||||
|
}
|
||||||
|
if got := sink.events[0].Payload["fingerprint"]; got == "" {
|
||||||
|
t.Fatalf("spawn_failed fingerprint = %#v, want non-empty", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSpawnEmitsTelemetryOnSuccess(t *testing.T) {
|
func TestSpawnEmitsTelemetryOnSuccess(t *testing.T) {
|
||||||
|
|
@ -395,11 +404,47 @@ func TestSpawnEmitsTelemetryOnFailure(t *testing.T) {
|
||||||
if got := ev.Payload["error_kind"]; got != "internal" {
|
if got := ev.Payload["error_kind"]; got != "internal" {
|
||||||
t.Fatalf("event payload error_kind = %#v, want internal", got)
|
t.Fatalf("event payload error_kind = %#v, want internal", got)
|
||||||
}
|
}
|
||||||
|
if got := ev.Payload["component"]; got != "session_service" {
|
||||||
|
t.Fatalf("event payload component = %#v, want session_service", got)
|
||||||
|
}
|
||||||
|
if got := ev.Payload["operation"]; got != "spawn_session" {
|
||||||
|
t.Fatalf("event payload operation = %#v, want spawn_session", got)
|
||||||
|
}
|
||||||
|
if got := ev.Payload["fingerprint"]; got == "" {
|
||||||
|
t.Fatalf("event payload fingerprint = %#v, want non-empty", got)
|
||||||
|
}
|
||||||
if _, ok := ev.Payload["error"]; ok {
|
if _, ok := ev.Payload["error"]; ok {
|
||||||
t.Fatalf("event payload leaked raw error: %+v", ev.Payload)
|
t.Fatalf("event payload leaked raw error: %+v", ev.Payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSpawnEmitsTypedErrorCodeOnFailure(t *testing.T) {
|
||||||
|
st := newFakeStore()
|
||||||
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer"}
|
||||||
|
fc := &fakeCommander{spawnErr: fmt.Errorf("spawn: %w: %q", sessionmanager.ErrUnknownHarness, "bogus")}
|
||||||
|
ts := &fakeTelemetrySink{}
|
||||||
|
svc := NewWithDeps(Deps{Manager: fc, Store: st, Telemetry: ts, Clock: func() time.Time { return time.Unix(1700000000, 0).UTC() }})
|
||||||
|
|
||||||
|
_, err := svc.Spawn(context.Background(), ports.SpawnConfig{
|
||||||
|
ProjectID: "mer",
|
||||||
|
Kind: domain.KindWorker,
|
||||||
|
Harness: domain.HarnessCodex,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Spawn error = nil, want failure")
|
||||||
|
}
|
||||||
|
if len(ts.events) != 1 {
|
||||||
|
t.Fatalf("telemetry events = %d, want 1", len(ts.events))
|
||||||
|
}
|
||||||
|
ev := ts.events[0]
|
||||||
|
if got := ev.Payload["error_kind"]; got != "invalid" {
|
||||||
|
t.Fatalf("event payload error_kind = %#v, want invalid", got)
|
||||||
|
}
|
||||||
|
if got := ev.Payload["error_code"]; got != "UNKNOWN_HARNESS" {
|
||||||
|
t.Fatalf("event payload error_code = %#v, want UNKNOWN_HARNESS", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestSpawnOrchestratorUnknownProjectReturns404 is the orchestrator-side guard
|
// TestSpawnOrchestratorUnknownProjectReturns404 is the orchestrator-side guard
|
||||||
// for Bug 1: same pre-validation, same typed envelope.
|
// for Bug 1: same pre-validation, same typed envelope.
|
||||||
func TestSpawnOrchestratorUnknownProjectReturns404(t *testing.T) {
|
func TestSpawnOrchestratorUnknownProjectReturns404(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
package telemetrymeta
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrorKindAndCode extracts a telemetry-safe error category and optional code.
|
||||||
|
func ErrorKindAndCode(err error) (kind, code string) {
|
||||||
|
kind = "internal"
|
||||||
|
var apiErr *apierr.Error
|
||||||
|
if errors.As(err, &apiErr) {
|
||||||
|
return ErrorKind(apiErr.Kind), apiErr.Code
|
||||||
|
}
|
||||||
|
return kind, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorKind maps API error kinds to coarse telemetry-safe categories.
|
||||||
|
func ErrorKind(kind apierr.Kind) string {
|
||||||
|
switch kind {
|
||||||
|
case apierr.KindInvalid:
|
||||||
|
return "invalid"
|
||||||
|
case apierr.KindNotFound:
|
||||||
|
return "not_found"
|
||||||
|
case apierr.KindConflict:
|
||||||
|
return "conflict"
|
||||||
|
default:
|
||||||
|
return "internal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PanicKind classifies panic payloads without exporting their raw contents.
|
||||||
|
func PanicKind(rec any) string {
|
||||||
|
switch rec.(type) {
|
||||||
|
case error:
|
||||||
|
return "error"
|
||||||
|
case string:
|
||||||
|
return "string"
|
||||||
|
default:
|
||||||
|
return "other"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusFamily returns a telemetry-friendly HTTP status bucket like 5xx.
|
||||||
|
func StatusFamily(status int) string {
|
||||||
|
if status < 100 || status > 999 {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dxx", status/100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoutePattern returns the chi route template when available, else the URL path.
|
||||||
|
func RoutePattern(r *http.Request) string {
|
||||||
|
if r == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if rc := chi.RouteContext(r.Context()); rc != nil {
|
||||||
|
if pattern := strings.TrimSpace(rc.RoutePattern()); pattern != "" {
|
||||||
|
return pattern
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.URL == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return r.URL.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fingerprint returns a short stable digest for grouping similar failures.
|
||||||
|
func Fingerprint(parts ...string) string {
|
||||||
|
h := sha256.New()
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, _ = h.Write([]byte(part))
|
||||||
|
_, _ = h.Write([]byte{0})
|
||||||
|
}
|
||||||
|
sum := hex.EncodeToString(h.Sum(nil))
|
||||||
|
if len(sum) > 16 {
|
||||||
|
return sum[:16]
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
package telemetrymeta
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestErrorKindAndCode(t *testing.T) {
|
||||||
|
kind, code := ErrorKindAndCode(apierr.NotFound("SESSION_NOT_FOUND", "Unknown session"))
|
||||||
|
if kind != "not_found" || code != "SESSION_NOT_FOUND" {
|
||||||
|
t.Fatalf("typed error = (%q, %q), want (not_found, SESSION_NOT_FOUND)", kind, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
kind, code = ErrorKindAndCode(errors.New("boom"))
|
||||||
|
if kind != "internal" || code != "" {
|
||||||
|
t.Fatalf("raw error = (%q, %q), want (internal, empty)", kind, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutePatternPrefersChiPattern(t *testing.T) {
|
||||||
|
var got string
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/api/v1/projects/{projectID}/sessions/{sessionID}", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
got = RoutePattern(req)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/mer/sessions/sess-1", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d, want 204", rec.Code)
|
||||||
|
}
|
||||||
|
if got != "/api/v1/projects/{projectID}/sessions/{sessionID}" {
|
||||||
|
t.Fatalf("route pattern = %q, want chi route pattern", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFingerprintStableForSameInputs(t *testing.T) {
|
||||||
|
first := Fingerprint("httpd", "http_request", "GET", "/api/v1/projects/{projectID}", "5xx", "internal")
|
||||||
|
second := Fingerprint("httpd", "http_request", "GET", "/api/v1/projects/{projectID}", "5xx", "internal")
|
||||||
|
other := Fingerprint("httpd", "http_request", "POST", "/api/v1/projects/{projectID}", "5xx", "internal")
|
||||||
|
|
||||||
|
if first == "" || len(first) != 16 {
|
||||||
|
t.Fatalf("fingerprint = %q, want 16-char digest", first)
|
||||||
|
}
|
||||||
|
if first != second {
|
||||||
|
t.Fatalf("fingerprints differ for same inputs: %q vs %q", first, second)
|
||||||
|
}
|
||||||
|
if first == other {
|
||||||
|
t.Fatalf("fingerprints should differ for different inputs: %q vs %q", first, other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
|
import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
|
||||||
import { apiClient, apiErrorMessage } from "../lib/api-client";
|
import { apiClient, apiErrorMessage } from "../lib/api-client";
|
||||||
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
|
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
|
||||||
import { captureRendererEvent, captureRendererException } from "../lib/telemetry";
|
import { addRendererExceptionStep, captureRendererEvent, captureRendererException } from "../lib/telemetry";
|
||||||
import { useUiStore } from "../stores/ui-store";
|
import { useUiStore } from "../stores/ui-store";
|
||||||
import { OrchestratorIcon } from "./icons";
|
import { OrchestratorIcon } from "./icons";
|
||||||
import { NewTaskDialog } from "./NewTaskDialog";
|
import { NewTaskDialog } from "./NewTaskDialog";
|
||||||
|
|
@ -92,6 +92,12 @@ export function ShellTopbar() {
|
||||||
|
|
||||||
const openOrchestrator = async () => {
|
const openOrchestrator = async () => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
|
void addRendererExceptionStep("Orchestrator open requested", {
|
||||||
|
source: "orchestrator-open",
|
||||||
|
operation: "open_orchestrator",
|
||||||
|
surface: isSessionRoute ? "session_detail" : "project_board",
|
||||||
|
project_id: projectId,
|
||||||
|
});
|
||||||
void captureRendererEvent("ao.renderer.orchestrator_open_requested", { project_id: projectId });
|
void captureRendererEvent("ao.renderer.orchestrator_open_requested", { project_id: projectId });
|
||||||
if (orchestrator) {
|
if (orchestrator) {
|
||||||
void navigate({
|
void navigate({
|
||||||
|
|
@ -109,7 +115,12 @@ export function ShellTopbar() {
|
||||||
params: { projectId, sessionId },
|
params: { projectId, sessionId },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
void captureRendererException(error, { source: "orchestrator-open", project_id: projectId });
|
void captureRendererException(error, {
|
||||||
|
source: "orchestrator-open",
|
||||||
|
operation: "open_orchestrator",
|
||||||
|
surface: isSessionRoute ? "session_detail" : "project_board",
|
||||||
|
project_id: projectId,
|
||||||
|
});
|
||||||
console.error("Failed to spawn orchestrator:", error);
|
console.error("Failed to spawn orchestrator:", error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSpawning(false);
|
setIsSpawning(false);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export class TelemetryBoundary extends React.Component<Props, State> {
|
||||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||||
void captureRendererException(error, {
|
void captureRendererException(error, {
|
||||||
source: "react-error-boundary",
|
source: "react-error-boundary",
|
||||||
|
operation: "react_render",
|
||||||
});
|
});
|
||||||
void info;
|
void info;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { routeSurface, sanitizeRendererExceptionProperties, sanitizeRendererProperties } from "./telemetry";
|
import {
|
||||||
|
routeSurface,
|
||||||
|
sanitizePostHogEvent,
|
||||||
|
sanitizeReplayRequestName,
|
||||||
|
sanitizeRendererExceptionProperties,
|
||||||
|
sanitizeRendererProperties,
|
||||||
|
} from "./telemetry";
|
||||||
|
|
||||||
describe("telemetry sanitizers", () => {
|
describe("telemetry sanitizers", () => {
|
||||||
it("categorizes routes without exporting raw paths", () => {
|
it("categorizes routes without exporting raw paths", () => {
|
||||||
|
|
@ -26,6 +32,7 @@ describe("telemetry sanitizers", () => {
|
||||||
it("strips exception details down to coarse metadata", async () => {
|
it("strips exception details down to coarse metadata", async () => {
|
||||||
const props = await sanitizeRendererExceptionProperties(new TypeError("local path /tmp/private"), {
|
const props = await sanitizeRendererExceptionProperties(new TypeError("local path /tmp/private"), {
|
||||||
source: "window-error",
|
source: "window-error",
|
||||||
|
operation: "project_add",
|
||||||
unhandled: true,
|
unhandled: true,
|
||||||
project_id: "demo-project",
|
project_id: "demo-project",
|
||||||
component_stack: "App > Shell",
|
component_stack: "App > Shell",
|
||||||
|
|
@ -33,10 +40,75 @@ describe("telemetry sanitizers", () => {
|
||||||
expect(props).toMatchObject({
|
expect(props).toMatchObject({
|
||||||
error_name: "TypeError",
|
error_name: "TypeError",
|
||||||
source: "window-error",
|
source: "window-error",
|
||||||
|
operation: "project_add",
|
||||||
unhandled: true,
|
unhandled: true,
|
||||||
});
|
});
|
||||||
expect(props).toHaveProperty("project_id_hash");
|
expect(props).toHaveProperty("project_id_hash");
|
||||||
expect(props).not.toHaveProperty("project_id");
|
expect(props).not.toHaveProperty("project_id");
|
||||||
expect(props).not.toHaveProperty("component_stack");
|
expect(props).not.toHaveProperty("component_stack");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sanitizes exception step context", async () => {
|
||||||
|
const props = await sanitizeRendererExceptionProperties(new Error("boom"), {
|
||||||
|
source: "orchestrator-open",
|
||||||
|
operation: "open_orchestrator",
|
||||||
|
surface: "session_detail",
|
||||||
|
project_id: "demo-project",
|
||||||
|
});
|
||||||
|
expect(props).toMatchObject({
|
||||||
|
source: "orchestrator-open",
|
||||||
|
operation: "open_orchestrator",
|
||||||
|
surface: "session_detail",
|
||||||
|
});
|
||||||
|
expect(props).toHaveProperty("project_id_hash");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts local urls and filesystem paths from outgoing PostHog payloads", () => {
|
||||||
|
const event = sanitizePostHogEvent({
|
||||||
|
event: "$exception",
|
||||||
|
properties: {
|
||||||
|
$current_url: "app://renderer/index.html?token=secret",
|
||||||
|
$initial_current_url: "file:///Users/alice/private/index.html",
|
||||||
|
message:
|
||||||
|
"failed to fetch http://localhost:3037/api/v1/projects?token=secret from app://renderer/index.html?token=secret and open /Users/alice/reverb/file.txt",
|
||||||
|
$exception_list: [
|
||||||
|
{
|
||||||
|
type: "TypeError",
|
||||||
|
value:
|
||||||
|
"failed to load /home/alice/.config/reverb/settings.json via http://127.0.0.1:3037/api/v1/projects?token=secret",
|
||||||
|
stacktrace: {
|
||||||
|
frames: [
|
||||||
|
{ filename: "file:///Users/alice/reverb/dist/main.js" },
|
||||||
|
{ filename: "http://[::1]:3037/api/v1/projects?token=secret" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const props = event.properties as Record<string, unknown>;
|
||||||
|
expect(props.$current_url).toBe("[redacted-local-url]");
|
||||||
|
expect(props.$initial_current_url).toBe("[redacted-local-url]");
|
||||||
|
expect(props.message).toBe(
|
||||||
|
"failed to fetch [redacted-local-url] from [redacted-local-url] and open [redacted-local-path]",
|
||||||
|
);
|
||||||
|
const exceptionList = props.$exception_list as Array<Record<string, unknown>>;
|
||||||
|
expect(exceptionList[0].value).toBe("failed to load [redacted-local-path] via [redacted-local-url]");
|
||||||
|
expect((exceptionList[0].stacktrace as { frames: Array<{ filename: string }> }).frames[0].filename).toBe(
|
||||||
|
"[redacted-local-url]",
|
||||||
|
);
|
||||||
|
expect((exceptionList[0].stacktrace as { frames: Array<{ filename: string }> }).frames[1].filename).toBe(
|
||||||
|
"[redacted-local-url]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts replay request names before they leave the renderer", () => {
|
||||||
|
expect(sanitizeReplayRequestName("file:///Users/alice/private/index.html?token=secret")).toBe(
|
||||||
|
"[redacted-local-url]",
|
||||||
|
);
|
||||||
|
expect(sanitizeReplayRequestName("http://[::1]:3037/api/v1/projects?token=secret")).toBe("[redacted-local-url]");
|
||||||
|
expect(sanitizeReplayRequestName("https://api.example.com/endpoint?token=secret")).toBe(
|
||||||
|
"https://api.example.com/endpoint",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ import { DEFAULT_POSTHOG_HOST, DEFAULT_POSTHOG_PROJECT_KEY } from "../../shared/
|
||||||
const POSTHOG_KEY = import.meta.env.VITE_AO_POSTHOG_KEY?.trim() || DEFAULT_POSTHOG_PROJECT_KEY;
|
const POSTHOG_KEY = import.meta.env.VITE_AO_POSTHOG_KEY?.trim() || DEFAULT_POSTHOG_PROJECT_KEY;
|
||||||
const POSTHOG_HOST = import.meta.env.VITE_AO_POSTHOG_HOST?.trim() || DEFAULT_POSTHOG_HOST;
|
const POSTHOG_HOST = import.meta.env.VITE_AO_POSTHOG_HOST?.trim() || DEFAULT_POSTHOG_HOST;
|
||||||
const RELEASE_TAG = "2026-01-30";
|
const RELEASE_TAG = "2026-01-30";
|
||||||
|
const REDACTED_LOCAL_URL = "[redacted-local-url]";
|
||||||
|
const REDACTED_LOCAL_PATH = "[redacted-local-path]";
|
||||||
|
const EMBEDDED_LOCAL_URL_PATTERN =
|
||||||
|
/(?:\bfile:\/\/\/\S+|\bapp:\/\/renderer\/\S+|\bhttps?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?\S*)/gi;
|
||||||
|
|
||||||
let initPromise: Promise<boolean> | null = null;
|
let initPromise: Promise<boolean> | null = null;
|
||||||
let errorHandlersBound = false;
|
let errorHandlersBound = false;
|
||||||
|
|
@ -48,6 +52,82 @@ async function hashedTelemetryID(value: unknown): Promise<string | undefined> {
|
||||||
return sha256Hex(trimmed);
|
return sha256Hex(trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLocalURL(value: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
const hostname = url.hostname.replace(/^\[(.*)\]$/, "$1");
|
||||||
|
return (
|
||||||
|
url.protocol === "file:" ||
|
||||||
|
(url.protocol === "app:" && url.host === "renderer") ||
|
||||||
|
hostname === "localhost" ||
|
||||||
|
hostname === "127.0.0.1" ||
|
||||||
|
hostname === "::1"
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactEmbeddedLocalURLs(value: string): string {
|
||||||
|
return value.replace(EMBEDDED_LOCAL_URL_PATTERN, REDACTED_LOCAL_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactEmbeddedAbsolutePaths(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/(?:\/Users\/|\/home\/|\/tmp\/|\/private\/var\/|\/var\/folders\/)\S+/g, REDACTED_LOCAL_PATH)
|
||||||
|
.replace(/\b[A-Za-z]:\\[^\s)]+/g, REDACTED_LOCAL_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSensitiveString(value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return trimmed;
|
||||||
|
if (isLocalURL(trimmed)) return REDACTED_LOCAL_URL;
|
||||||
|
return redactEmbeddedAbsolutePaths(redactEmbeddedLocalURLs(trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePostHogValue(value: unknown): unknown {
|
||||||
|
if (typeof value === "string") return sanitizeSensitiveString(value);
|
||||||
|
if (Array.isArray(value)) return value.map((item) => sanitizePostHogValue(item));
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, sanitizePostHogValue(nested)]));
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizePostHogEvent(event: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
return sanitizePostHogValue(event) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeReplayRequestName(name: string): string {
|
||||||
|
const withoutQuery = name.split("?")[0] ?? name;
|
||||||
|
return sanitizeSensitiveString(withoutQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePostHogCaptureResult<T>(event: T): T {
|
||||||
|
return sanitizePostHogEvent(event as unknown as Record<string, unknown>) as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sanitizeRendererContextProperties(properties?: TelemetryProperties): Promise<TelemetryProperties> {
|
||||||
|
const safe: TelemetryProperties = {};
|
||||||
|
if (typeof properties?.source === "string" && properties.source.trim() !== "") {
|
||||||
|
safe.source = properties.source;
|
||||||
|
}
|
||||||
|
if (typeof properties?.operation === "string" && properties.operation.trim() !== "") {
|
||||||
|
safe.operation = properties.operation;
|
||||||
|
}
|
||||||
|
if (typeof properties?.surface === "string" && properties.surface.trim() !== "") {
|
||||||
|
safe.surface = properties.surface;
|
||||||
|
}
|
||||||
|
if (typeof properties?.unhandled === "boolean") {
|
||||||
|
safe.unhandled = properties.unhandled;
|
||||||
|
}
|
||||||
|
const projectIDHash = await hashedTelemetryID(properties?.project_id);
|
||||||
|
if (projectIDHash) {
|
||||||
|
safe.project_id_hash = projectIDHash;
|
||||||
|
}
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
|
|
||||||
export async function sanitizeRendererProperties(
|
export async function sanitizeRendererProperties(
|
||||||
event: string,
|
event: string,
|
||||||
properties?: TelemetryProperties,
|
properties?: TelemetryProperties,
|
||||||
|
|
@ -89,17 +169,7 @@ export async function sanitizeRendererExceptionProperties(
|
||||||
const safe: TelemetryProperties = {
|
const safe: TelemetryProperties = {
|
||||||
error_name: exceptionName(error),
|
error_name: exceptionName(error),
|
||||||
};
|
};
|
||||||
if (typeof properties?.source === "string" && properties.source.trim() !== "") {
|
return { ...safe, ...(await sanitizeRendererContextProperties(properties)) };
|
||||||
safe.source = properties.source;
|
|
||||||
}
|
|
||||||
if (typeof properties?.unhandled === "boolean") {
|
|
||||||
safe.unhandled = properties.unhandled;
|
|
||||||
}
|
|
||||||
const projectIDHash = await hashedTelemetryID(properties?.project_id);
|
|
||||||
if (projectIDHash) {
|
|
||||||
safe.project_id_hash = projectIDHash;
|
|
||||||
}
|
|
||||||
return safe;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindErrorHandlers() {
|
function bindErrorHandlers() {
|
||||||
|
|
@ -130,7 +200,17 @@ export async function initTelemetry(): Promise<boolean> {
|
||||||
defaults: RELEASE_TAG,
|
defaults: RELEASE_TAG,
|
||||||
autocapture: false,
|
autocapture: false,
|
||||||
capture_pageview: false,
|
capture_pageview: false,
|
||||||
|
capture_exceptions: false,
|
||||||
persistence: "localStorage",
|
persistence: "localStorage",
|
||||||
|
before_send: (event) => (event ? sanitizePostHogCaptureResult(event) : event),
|
||||||
|
session_recording: {
|
||||||
|
maskCapturedNetworkRequestFn: (request) => {
|
||||||
|
if (request.name) {
|
||||||
|
request.name = sanitizeReplayRequestName(request.name);
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
posthog.identify(bootstrap.distinctId, {
|
posthog.identify(bootstrap.distinctId, {
|
||||||
app_version: bootstrap.appVersion,
|
app_version: bootstrap.appVersion,
|
||||||
|
|
@ -160,7 +240,13 @@ export async function captureRendererEvent(event: string, properties?: Record<st
|
||||||
export async function captureRendererException(error: unknown, properties?: Record<string, unknown>): Promise<void> {
|
export async function captureRendererException(error: unknown, properties?: Record<string, unknown>): Promise<void> {
|
||||||
if (!(await initTelemetry())) return;
|
if (!(await initTelemetry())) return;
|
||||||
const safeProperties = await sanitizeRendererExceptionProperties(error, properties);
|
const safeProperties = await sanitizeRendererExceptionProperties(error, properties);
|
||||||
posthog.capture("ao.renderer.exception", safeProperties);
|
posthog.captureException(normalizeException(error), safeProperties);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addRendererExceptionStep(message: string, properties?: Record<string, unknown>): Promise<void> {
|
||||||
|
if (!(await initTelemetry())) return;
|
||||||
|
const safeProperties = await sanitizeRendererContextProperties(properties);
|
||||||
|
posthog.addExceptionStep(message, safeProperties);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { routeSurface };
|
export { routeSurface };
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { useDaemonStatus } from "../hooks/useDaemonStatus";
|
||||||
import { useWorkspaceQuery, workspaceQueryKey, workspaceQueryOptions } from "../hooks/useWorkspaceQuery";
|
import { useWorkspaceQuery, workspaceQueryKey, workspaceQueryOptions } from "../hooks/useWorkspaceQuery";
|
||||||
import { apiClient, apiErrorMessage } from "../lib/api-client";
|
import { apiClient, apiErrorMessage } from "../lib/api-client";
|
||||||
import { refreshDaemonStatus } from "../lib/daemon-status";
|
import { refreshDaemonStatus } from "../lib/daemon-status";
|
||||||
import { captureRendererEvent, captureRendererException } from "../lib/telemetry";
|
import { addRendererExceptionStep, captureRendererEvent, captureRendererException } from "../lib/telemetry";
|
||||||
import { ShellProvider } from "../lib/shell-context";
|
import { ShellProvider } from "../lib/shell-context";
|
||||||
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
|
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
|
||||||
import { readStoredTheme, type Theme, useUiStore } from "../stores/ui-store";
|
import { readStoredTheme, type Theme, useUiStore } from "../stores/ui-store";
|
||||||
|
|
@ -57,6 +57,11 @@ function ShellLayout() {
|
||||||
|
|
||||||
const createProject = useCallback(
|
const createProject = useCallback(
|
||||||
async (input: { path: string; workerAgent: string; orchestratorAgent: string }) => {
|
async (input: { path: string; workerAgent: string; orchestratorAgent: string }) => {
|
||||||
|
void addRendererExceptionStep("Project add requested", {
|
||||||
|
source: "project-add",
|
||||||
|
operation: "project_add",
|
||||||
|
surface: "project_board",
|
||||||
|
});
|
||||||
void captureRendererEvent("ao.renderer.project_add_requested");
|
void captureRendererEvent("ao.renderer.project_add_requested");
|
||||||
const { data, error } = await apiClient.POST("/api/v1/projects", {
|
const { data, error } = await apiClient.POST("/api/v1/projects", {
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -69,7 +74,11 @@ function ShellLayout() {
|
||||||
});
|
});
|
||||||
if (error) {
|
if (error) {
|
||||||
const failure = new Error(apiErrorMessage(error));
|
const failure = new Error(apiErrorMessage(error));
|
||||||
void captureRendererException(failure, { source: "project-add" });
|
void captureRendererException(failure, {
|
||||||
|
source: "project-add",
|
||||||
|
operation: "project_add",
|
||||||
|
surface: "project_board",
|
||||||
|
});
|
||||||
throw failure;
|
throw failure;
|
||||||
}
|
}
|
||||||
if (!data?.project) throw new Error("Project creation returned no project");
|
if (!data?.project) throw new Error("Project creation returned no project");
|
||||||
|
|
@ -101,12 +110,23 @@ function ShellLayout() {
|
||||||
|
|
||||||
const removeProject = useCallback(
|
const removeProject = useCallback(
|
||||||
async (projectId: string) => {
|
async (projectId: string) => {
|
||||||
|
void addRendererExceptionStep("Project removal requested", {
|
||||||
|
source: "project-remove",
|
||||||
|
operation: "project_remove",
|
||||||
|
surface: "project_board",
|
||||||
|
project_id: projectId,
|
||||||
|
});
|
||||||
const { error } = await apiClient.DELETE("/api/v1/projects/{id}", {
|
const { error } = await apiClient.DELETE("/api/v1/projects/{id}", {
|
||||||
params: { path: { id: projectId } },
|
params: { path: { id: projectId } },
|
||||||
});
|
});
|
||||||
if (error) {
|
if (error) {
|
||||||
const failure = new Error(apiErrorMessage(error));
|
const failure = new Error(apiErrorMessage(error));
|
||||||
void captureRendererException(failure, { source: "project-remove", project_id: projectId });
|
void captureRendererException(failure, {
|
||||||
|
source: "project-remove",
|
||||||
|
operation: "project_remove",
|
||||||
|
surface: "project_board",
|
||||||
|
project_id: projectId,
|
||||||
|
});
|
||||||
throw failure;
|
throw failure;
|
||||||
}
|
}
|
||||||
void captureRendererEvent("ao.renderer.project_removed", { project_id: projectId });
|
void captureRendererEvent("ao.renderer.project_removed", { project_id: projectId });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue