From 3c52ba9afae8a9bc8b5cd537e6085f444fece800 Mon Sep 17 00:00:00 2001 From: skota-hash Date: Sat, 25 Apr 2026 14:04:18 -0400 Subject: [PATCH] feat(schema): v2.0 schema foundation + Go validator (Phase 1 slice 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land the structural foundation Phase 1 needs before any SDK rewrite: the v2.0 wide-event JSON Schema, the v1.1 bridge schema, six golden fixtures, and a coexisting pkg/event/v2 Go package with types, schema- backed Validate, structural parity test, and a v1.1 bridge accept test. The existing v1.1 pkg/event stays intact during the bridge window. - docs/schema/v2.0.json — JSON Schema (draft 2020-12) for the v2 wide event. Encodes the §3.2 invariants: status, schema_version, event_id, ts_start, ts_end, kind, service, env, trace_id all required; status ∈ {error, timeout, partial, aborted} ⇒ anchor required; step.downstream is present ⇒ step.span_id required. - docs/schema/v1.1.json — loose bridge schema accepting the current v1.x shape; additive only. - testdata/fixtures/v2/{ok-simple, error-payment-cascade, timeout- watchdog, aborted-cancel, suppressed-healthcheck, error-panic}.json — six golden scenarios covering every status and every reserved lifecycle code (WAYLOG_TIMEOUT/ABORTED/PANIC + PMT_502). - pkg/event/v2 — new package eventv2: schema 2.0 types (Event/Anchor/Step/Downstream/StepError/Log/ErrorRef), Status and reserved-code constants, Validate(*Event) and ValidateFile against the v2.0 schema. SpanID and ParentSpanID intentionally lack omitempty: SDK-emitted events always carry both as string fields per §3.2 (parent_span_id empty for root-of-trace). Coexists with v1.1 pkg/event. - pkg/go.mod — adds github.com/santhosh-tekuri/jsonschema/v5 v5.3.1. Tests: - TestFixturesValidateAgainstSchema — every fixture validates against docs/schema/v2.0.json. - TestFixtureRoundTripStructural — for each fixture, unmarshal into Event, marshal back, mask runtime-generated fields with non-empty sentinels (so explicit empty-string parent_span_id survives normalization), normalize semantic-empty values, structurally compare. Catches JSON-tag drift, omitempty on must-have-fields, and silent field drops on the SDK-contract-required identity surface. - TestV11BridgeSchemaAcceptsCurrentV1Event — proves the v1.1 schema is in lockstep with the actual pkg/event.WideEvent shape during the bridge window. - TestV11BridgeSchemaRejectsMissingTraceID — sanity check that the bridge schema is not vacuous. - TestV20SchemaRequiresStatus — locks in that an event without status is rejected. - TestEventRoundTrip, TestStatusConstants — basic shape sanity. Verification: - cd pkg && go test ./event/v2/... -v → 17/17 PASS - go test ./... at repo root → all packages green; v1.1 pkg/event untouched. - go build ./... at repo root → clean. --- docs/schema/v1.1.json | 90 ++++++++++ docs/schema/v2.0.json | 98 +++++++++++ pkg/event/v2/bridge_test.go | 123 ++++++++++++++ pkg/event/v2/event.go | 146 ++++++++++++++++ pkg/event/v2/event_test.go | 47 ++++++ pkg/event/v2/parity_test.go | 159 ++++++++++++++++++ pkg/event/v2/schema_test.go | 36 ++++ pkg/go.mod | 2 + pkg/go.sum | 2 + testdata/fixtures/v2/aborted-cancel.json | 19 +++ testdata/fixtures/v2/error-panic.json | 19 +++ .../fixtures/v2/error-payment-cascade.json | 32 ++++ testdata/fixtures/v2/ok-simple.json | 20 +++ .../fixtures/v2/suppressed-healthcheck.json | 18 ++ testdata/fixtures/v2/timeout-watchdog.json | 22 +++ 15 files changed, 833 insertions(+) create mode 100644 docs/schema/v1.1.json create mode 100644 docs/schema/v2.0.json create mode 100644 pkg/event/v2/bridge_test.go create mode 100644 pkg/event/v2/event.go create mode 100644 pkg/event/v2/event_test.go create mode 100644 pkg/event/v2/parity_test.go create mode 100644 pkg/event/v2/schema_test.go create mode 100644 pkg/go.sum create mode 100644 testdata/fixtures/v2/aborted-cancel.json create mode 100644 testdata/fixtures/v2/error-panic.json create mode 100644 testdata/fixtures/v2/error-payment-cascade.json create mode 100644 testdata/fixtures/v2/ok-simple.json create mode 100644 testdata/fixtures/v2/suppressed-healthcheck.json create mode 100644 testdata/fixtures/v2/timeout-watchdog.json diff --git a/docs/schema/v1.1.json b/docs/schema/v1.1.json new file mode 100644 index 0000000..7121139 --- /dev/null +++ b/docs/schema/v1.1.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://waylog.dev/schema/v1.1.json", + "title": "Waylog Wide Event v1.1 (bridge)", + "type": "object", + "required": ["schema_version", "event_name", "timestamp", "request", "system", "outcome"], + "properties": { + "schema_version": { "type": "string", "pattern": "^1\\.[0-9]+$" }, + "event_name": { "type": "string" }, + "timestamp": { "type": "string", "format": "date-time" }, + "user": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "tier": { "type": "string" }, + "region": { "type": "string" }, + "vip": { "type": "boolean" } + }, + "additionalProperties": true + }, + "request": { + "type": "object", + "required": ["trace_id"], + "properties": { + "trace_id": { "type": "string", "pattern": "^[0-9a-f]{32}$" }, + "span_id": { "type": "string" }, + "parent_span_id": { "type": "string" }, + "http_method": { "type": "string" }, + "route_template": { "type": "string" }, + "flow": { "type": "string" }, + "feature_flags": { "type": "array", "items": { "type": "string" } }, + "correlation_id": { "type": "string" }, + "attempt": { "type": "integer" }, + "transport_kind": { "type": "string" } + }, + "additionalProperties": true + }, + "system": { + "type": "object", + "required": ["service", "env"], + "properties": { + "service": { "type": "string" }, + "version": { "type": "string" }, + "deployment_id": { "type": "string" }, + "env": { "type": "string" }, + "downstream_service": { "type": "string" }, + "caller_service": { "type": "string" } + }, + "additionalProperties": true + }, + "outcome": { + "type": "object", + "required": ["success", "status_code", "kind"], + "properties": { + "success": { "type": "boolean" }, + "status_code": { "type": "integer" }, + "kind": { "type": "string" } + }, + "additionalProperties": true + }, + "error": { + "type": "object", + "properties": { + "code": { "type": "string" }, + "path": { "type": "string" }, + "message": { "type": "string" }, + "reason": { "type": "string" } + }, + "additionalProperties": true + }, + "metrics": { + "type": "object", + "properties": { + "latency_ms": { "type": "integer" } + }, + "additionalProperties": true + }, + "parent_request_id": { "type": "string" }, + "metadata": { "type": "object" }, + "retry": { + "type": "object", + "properties": { + "of": { "type": "integer" }, + "previous_attempt_id": { "type": "string" } + }, + "additionalProperties": true + } + }, + "additionalProperties": true +} diff --git a/docs/schema/v2.0.json b/docs/schema/v2.0.json new file mode 100644 index 0000000..7cdbe97 --- /dev/null +++ b/docs/schema/v2.0.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://waylog.dev/schema/v2.0.json", + "title": "Waylog Wide Event v2.0", + "type": "object", + "required": ["schema_version", "event_id", "ts_start", "ts_end", "kind", "service", "env", "trace_id", "status"], + "properties": { + "schema_version": { "const": "2.0" }, + "event_id": { "type": "string", "format": "uuid" }, + "ts_start": { "type": "string", "format": "date-time" }, + "ts_end": { "type": "string", "format": "date-time" }, + "duration_ms": { "type": "integer", "minimum": 0 }, + "kind": { "const": "http" }, + "service": { "type": "string", "minLength": 1 }, + "env": { "type": "string", "minLength": 1 }, + "version": { "type": "string" }, + "trace_id": { "type": "string", "pattern": "^[0-9a-f]{32}$" }, + "span_id": { "type": "string", "pattern": "^([0-9a-f]{16})?$" }, + "parent_span_id": { "type": "string", "pattern": "^([0-9a-f]{16})?$" }, + "status": { "enum": ["ok", "error", "timeout", "partial", "aborted", "suppressed"] }, + "anchor": { + "type": "object", + "required": ["step", "error_code"], + "properties": { + "step": { "type": "string", "minLength": 1 }, + "error_code": { "type": "string", "minLength": 1 }, + "kind": { "type": "string" } + } + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "start_ms", "duration_ms", "status"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "span_id": { "type": "string", "pattern": "^([0-9a-f]{16})?$" }, + "start_ms": { "type": "integer", "minimum": 0 }, + "duration_ms": { "type": "integer", "minimum": 0 }, + "status": { "enum": ["ok", "error"] }, + "downstream": { + "type": "object", + "properties": { + "service": { "type": "string" }, + "endpoint": { "type": "string" }, + "kind": { "type": "string" } + } + }, + "error": { + "type": "object", + "properties": { + "code": { "type": "string" }, + "reason": { "type": "string" }, + "cause": { "type": "string" } + } + } + }, + "allOf": [ + { + "if": { "required": ["downstream"] }, + "then": { "required": ["span_id"] } + } + ] + } + }, + "logs": { + "type": "array", + "items": { + "type": "object", + "required": ["ts_offset_ms", "level", "msg"], + "properties": { + "ts_offset_ms": { "type": "integer", "minimum": 0 }, + "level": { "enum": ["info", "warn", "error"] }, + "msg": { "type": "string" }, + "fields": { "type": "object" } + } + } + }, + "fields": { "type": "object" }, + "errors": { + "type": "array", + "items": { + "type": "object", + "required": ["code"], + "properties": { + "code": { "type": "string" }, + "reason": { "type": "string" } + } + } + } + }, + "allOf": [ + { + "if": { "properties": { "status": { "enum": ["error", "timeout", "partial", "aborted"] } } }, + "then": { "required": ["anchor"] } + } + ] +} diff --git a/pkg/event/v2/bridge_test.go b/pkg/event/v2/bridge_test.go new file mode 100644 index 0000000..314661a --- /dev/null +++ b/pkg/event/v2/bridge_test.go @@ -0,0 +1,123 @@ +package eventv2 + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/sssmaran/WaylogCLI/pkg/event" +) + +// TestV11BridgeSchemaAcceptsCurrentV1Event proves the bridge schema at +// docs/schema/v1.1.json is in lockstep with the actual v1 event.WideEvent +// type. If the v1.1 type changes during the bridge window, this test will +// fail until the schema is updated to match. +func TestV11BridgeSchemaAcceptsCurrentV1Event(t *testing.T) { + e := event.WideEvent{ + SchemaVersion: "1.1", + EventName: "checkout.request", + Timestamp: time.Date(2026, 4, 25, 14, 0, 0, 0, time.UTC), + User: event.UserContext{ + ID: "u_123", + Tier: "standard", + }, + Request: event.RequestContext{ + TraceID: "11111111111111111111111111111111", + SpanID: "1111111111111111", + HTTPMethod: "POST", + RouteTemplate: "/checkout", + Flow: "purchase", + FeatureFlags: []string{}, + }, + System: event.SystemContext{ + Service: "checkout", + Env: "test", + Version: "1.0.0", + }, + Outcome: event.OutcomeContext{ + Success: true, + StatusCode: 200, + Kind: "http", + }, + } + + raw, err := json.Marshal(&e) + if err != nil { + t.Fatalf("marshal v1 event: %v", err) + } + + schemaPath, err := filepath.Abs("../../../docs/schema/v1.1.json") + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(schemaPath); err != nil { + t.Fatalf("v1.1 schema missing at %s: %v", schemaPath, err) + } + + sch, err := compileSchema(schemaPath) + if err != nil { + t.Fatalf("compile v1.1 schema: %v", err) + } + + var v any + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("re-unmarshal: %v", err) + } + if err := sch.Validate(v); err != nil { + t.Fatalf("v1.1 bridge schema must accept current v1 WideEvent: %v\npayload: %s", err, raw) + } +} + +// TestV11BridgeSchemaRejectsMissingTraceID is a sanity check that the bridge +// schema is not vacuous — it should still reject a v1 event that lacks the +// one field every consumer needs (request.trace_id). +func TestV11BridgeSchemaRejectsMissingTraceID(t *testing.T) { + bad := map[string]any{ + "schema_version": "1.1", + "event_name": "checkout.request", + "timestamp": "2026-04-25T14:00:00Z", + "request": map[string]any{}, // missing trace_id + "system": map[string]any{"service": "checkout", "env": "test"}, + "outcome": map[string]any{"success": true, "status_code": 200, "kind": "http"}, + } + + schemaPath, _ := filepath.Abs("../../../docs/schema/v1.1.json") + sch, err := compileSchema(schemaPath) + if err != nil { + t.Fatalf("compile: %v", err) + } + if err := sch.Validate(bad); err == nil { + t.Fatal("v1.1 schema must reject events with empty request (no trace_id)") + } +} + +// TestV20SchemaRequiresStatus locks in that the v2.0 schema treats status as a +// required top-level field, matching the rest of the implementation and the +// product spec's triage model. +func TestV20SchemaRequiresStatus(t *testing.T) { + schemaPath, err := filepath.Abs("../../../docs/schema/v2.0.json") + if err != nil { + t.Fatal(err) + } + sch, err := compileSchema(schemaPath) + if err != nil { + t.Fatalf("compile v2.0 schema: %v", err) + } + + // All currently-required fields except status. Should be rejected. + bad := map[string]any{ + "schema_version": "2.0", + "event_id": "00000000-0000-4000-8000-000000000099", + "ts_start": "2026-04-25T14:00:00.000Z", + "ts_end": "2026-04-25T14:00:00.010Z", + "kind": "http", + "service": "checkout", + "env": "test", + "trace_id": "99999999999999999999999999999999", + } + if err := sch.Validate(bad); err == nil { + t.Fatal("v2.0 schema must reject events missing status") + } +} diff --git a/pkg/event/v2/event.go b/pkg/event/v2/event.go new file mode 100644 index 0000000..9d582fe --- /dev/null +++ b/pkg/event/v2/event.go @@ -0,0 +1,146 @@ +// Package eventv2 defines the Waylog v2.0 wide-event schema, types, and +// JSON Schema validation helpers. +package eventv2 + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "time" + + "github.com/santhosh-tekuri/jsonschema/v5" +) + +const SchemaVersion2 = "2.0" + +type Status string + +const ( + StatusOK Status = "ok" + StatusError Status = "error" + StatusTimeout Status = "timeout" + StatusPartial Status = "partial" + StatusAborted Status = "aborted" + StatusSuppressed Status = "suppressed" +) + +const ( + CodeTimeout = "WAYLOG_TIMEOUT" + CodeAborted = "WAYLOG_ABORTED" + CodePanic = "WAYLOG_PANIC" + CodePartial = "WAYLOG_PARTIAL" +) + +type Event struct { + SchemaVersion string `json:"schema_version"` + EventID string `json:"event_id"` + TsStart time.Time `json:"ts_start"` + TsEnd time.Time `json:"ts_end"` + DurationMS int64 `json:"duration_ms"` + Kind string `json:"kind"` + Service string `json:"service"` + Env string `json:"env"` + Version string `json:"version,omitempty"` + TraceID string `json:"trace_id"` + SpanID string `json:"span_id"` + ParentSpanID string `json:"parent_span_id"` + Status Status `json:"status"` + Anchor *Anchor `json:"anchor,omitempty"` + Steps []Step `json:"steps,omitempty"` + Logs []Log `json:"logs,omitempty"` + Fields map[string]any `json:"fields,omitempty"` + Errors []ErrorRef `json:"errors,omitempty"` +} + +type Anchor struct { + Step string `json:"step"` + ErrorCode string `json:"error_code"` + Kind string `json:"kind,omitempty"` +} + +type Step struct { + Name string `json:"name"` + SpanID string `json:"span_id,omitempty"` + StartMS int64 `json:"start_ms"` + DurationMS int64 `json:"duration_ms"` + Status string `json:"status"` // schema-restricted to "ok" or "error" + Downstream *Downstream `json:"downstream,omitempty"` + Error *StepError `json:"error,omitempty"` +} + +type Downstream struct { + Service string `json:"service,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + Kind string `json:"kind,omitempty"` +} + +type StepError struct { + Code string `json:"code,omitempty"` + Reason string `json:"reason,omitempty"` + Cause string `json:"cause,omitempty"` +} + +type Log struct { + TsOffsetMS int64 `json:"ts_offset_ms"` + Level string `json:"level"` + Msg string `json:"msg"` + Fields map[string]any `json:"fields,omitempty"` +} + +type ErrorRef struct { + Code string `json:"code"` + Reason string `json:"reason,omitempty"` +} + +// Validate checks an in-memory Event against the v2.0 JSON Schema at schemaPath. +func Validate(schemaPath string, e *Event) error { + raw, err := json.Marshal(e) + if err != nil { + return err + } + var v any + if err := json.Unmarshal(raw, &v); err != nil { + return err + } + return validateAny(schemaPath, v) +} + +// ValidateFile reads a JSON file from disk and validates it against schemaPath. +func ValidateFile(schemaPath, eventPath string) error { + raw, err := os.ReadFile(eventPath) + if err != nil { + return fmt.Errorf("read event: %w", err) + } + var v any + if err := json.Unmarshal(raw, &v); err != nil { + return fmt.Errorf("parse event: %w", err) + } + return validateAny(schemaPath, v) +} + +func validateAny(schemaPath string, v any) error { + sch, err := compileSchema(schemaPath) + if err != nil { + return err + } + return sch.Validate(v) +} + +const schemaResourceID = "schema" + +func compileSchema(schemaPath string) (*jsonschema.Schema, error) { + raw, err := os.ReadFile(schemaPath) + if err != nil { + return nil, fmt.Errorf("read schema: %w", err) + } + c := jsonschema.NewCompiler() + if err := c.AddResource(schemaResourceID, bytes.NewReader(raw)); err != nil { + return nil, fmt.Errorf("add schema resource: %w", err) + } + sch, err := c.Compile(schemaResourceID) + if err != nil { + return nil, fmt.Errorf("compile schema: %w", err) + } + return sch, nil +} diff --git a/pkg/event/v2/event_test.go b/pkg/event/v2/event_test.go new file mode 100644 index 0000000..d16f74b --- /dev/null +++ b/pkg/event/v2/event_test.go @@ -0,0 +1,47 @@ +package eventv2 + +import ( + "encoding/json" + "testing" + "time" +) + +func TestEventRoundTrip(t *testing.T) { + e := Event{ + SchemaVersion: SchemaVersion2, + EventID: "e1f2c3d4-0000-4000-a000-000000000001", + TsStart: time.Date(2026, 4, 24, 12, 0, 0, 0, time.UTC), + TsEnd: time.Date(2026, 4, 24, 12, 0, 1, 0, time.UTC), + DurationMS: 1000, + Kind: "http", + Service: "checkout", + Env: "test", + TraceID: "0123456789abcdef0123456789abcdef", + SpanID: "fedcba9876543210", + ParentSpanID: "", + Status: StatusOK, + Fields: map[string]any{"http": map[string]any{"route": "/x"}}, + } + + raw, err := json.Marshal(&e) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var back Event + if err := json.Unmarshal(raw, &back); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if back.EventID != e.EventID || back.Service != e.Service || back.Status != e.Status { + t.Fatalf("round-trip mismatch: %+v", back) + } +} + +func TestStatusConstants(t *testing.T) { + cases := []Status{StatusOK, StatusError, StatusTimeout, StatusPartial, StatusAborted, StatusSuppressed} + for _, s := range cases { + if s == "" { + t.Fatalf("empty status constant") + } + } +} diff --git a/pkg/event/v2/parity_test.go b/pkg/event/v2/parity_test.go new file mode 100644 index 0000000..66fcbfd --- /dev/null +++ b/pkg/event/v2/parity_test.go @@ -0,0 +1,159 @@ +package eventv2 + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" +) + +// TestFixtureRoundTripStructural compares each fixture against itself after a +// round-trip through Event, using masking + normalization rather than byte +// equality — RFC3339 formatting and nil/empty slice distinctions are not +// byte-stable but are semantically equivalent. +func TestFixtureRoundTripStructural(t *testing.T) { + fixtures, err := filepath.Glob("../../../testdata/fixtures/v2/*.json") + if err != nil { + t.Fatalf("glob fixtures: %v", err) + } + if len(fixtures) == 0 { + t.Fatal("no fixtures found") + } + + for _, fp := range fixtures { + t.Run(filepath.Base(fp), func(t *testing.T) { + t.Parallel() + raw, err := os.ReadFile(fp) + if err != nil { + t.Fatalf("read: %v", err) + } + + var typed Event + if err := json.Unmarshal(raw, &typed); err != nil { + t.Fatalf("unmarshal into Event: %v", err) + } + roundTripped, err := json.Marshal(&typed) + if err != nil { + t.Fatalf("marshal Event: %v", err) + } + + origN := normalizeForCompare(t, raw) + rtN := normalizeForCompare(t, roundTripped) + + if !reflect.DeepEqual(origN, rtN) { + origPretty, _ := json.MarshalIndent(origN, "", " ") + rtPretty, _ := json.MarshalIndent(rtN, "", " ") + t.Errorf("structural mismatch for %s\n--- fixture (normalized) ---\n%s\n--- round-trip (normalized) ---\n%s", + filepath.Base(fp), origPretty, rtPretty) + } + }) + } +} + +func normalizeForCompare(t *testing.T, raw []byte) any { + t.Helper() + var v any + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("normalize unmarshal: %v", err) + } + return normalize(maskNondeterministic(v)) +} + +// maskNondeterministic replaces SDK-runtime-generated field values with stable +// non-empty sentinels so the parity test gives the same answer for pinned +// fixtures and for live SDK output. The mask uses a *non-empty* sentinel for +// explicit empty-string identity values too — so a root-of-trace +// `parent_span_id: ""` survives the later normalize() step. Without that, a +// round-trip that dropped `parent_span_id` entirely (e.g. via `omitempty`) +// would silently compare equal to a fixture that explicitly set it to "", +// hiding a real schema-contract regression. +func maskNondeterministic(v any) any { + const ( + sentinelPresent = "__MASKED__" + sentinelEmpty = "__MASKED_EMPTY__" + ) + masked := map[string]struct{}{ + "event_id": {}, + "ts_start": {}, + "ts_end": {}, + "trace_id": {}, + "span_id": {}, // also matches steps[].span_id by name + "parent_span_id": {}, + } + var walk func(any) any + walk = func(node any) any { + switch x := node.(type) { + case map[string]any: + out := make(map[string]any, len(x)) + for k, vv := range x { + if _, ok := masked[k]; ok { + if s, isStr := vv.(string); isStr && s == "" { + out[k] = sentinelEmpty + } else { + out[k] = sentinelPresent + } + continue + } + out[k] = walk(vv) + } + return out + case []any: + out := make([]any, len(x)) + for i, e := range x { + out[i] = walk(e) + } + return out + default: + return x + } + } + return walk(v) +} + +// normalize collapses semantically-empty values (nil, "", [], {}) so that an +// omitempty struct field marshalling away matches a fixture that explicitly +// included an empty array or empty object. +func normalize(v any) any { + switch x := v.(type) { + case map[string]any: + out := make(map[string]any, len(x)) + for k, vv := range x { + n := normalize(vv) + if isSemanticEmpty(n) { + continue + } + out[k] = n + } + if len(out) == 0 { + return nil + } + return out + case []any: + if len(x) == 0 { + return nil + } + out := make([]any, len(x)) + for i, vv := range x { + out[i] = normalize(vv) + } + return out + default: + return x + } +} + +func isSemanticEmpty(v any) bool { + switch x := v.(type) { + case nil: + return true + case string: + return x == "" + case map[string]any: + return len(x) == 0 + case []any: + return len(x) == 0 + default: + return false + } +} diff --git a/pkg/event/v2/schema_test.go b/pkg/event/v2/schema_test.go new file mode 100644 index 0000000..1ee78a4 --- /dev/null +++ b/pkg/event/v2/schema_test.go @@ -0,0 +1,36 @@ +package eventv2 + +import ( + "os" + "path/filepath" + "testing" +) + +// TestFixturesValidateAgainstSchema compiles the v2.0 schema and asserts each +// golden fixture passes validation. +func TestFixturesValidateAgainstSchema(t *testing.T) { + schemaPath, err := filepath.Abs("../../../docs/schema/v2.0.json") + if err != nil { + t.Fatalf("abs schema path: %v", err) + } + if _, err := os.Stat(schemaPath); err != nil { + t.Fatalf("schema not found: %v", err) + } + + matches, err := filepath.Glob("../../../testdata/fixtures/v2/*.json") + if err != nil { + t.Fatalf("glob fixtures: %v", err) + } + if len(matches) == 0 { + t.Fatalf("no fixtures found") + } + + for _, fixture := range matches { + t.Run(filepath.Base(fixture), func(t *testing.T) { + t.Parallel() + if err := ValidateFile(schemaPath, fixture); err != nil { + t.Fatalf("fixture %s failed validation: %v", fixture, err) + } + }) + } +} diff --git a/pkg/go.mod b/pkg/go.mod index 270885a..abd30e1 100644 --- a/pkg/go.mod +++ b/pkg/go.mod @@ -1,3 +1,5 @@ module github.com/sssmaran/WaylogCLI/pkg go 1.24.2 + +require github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 diff --git a/pkg/go.sum b/pkg/go.sum new file mode 100644 index 0000000..0daed30 --- /dev/null +++ b/pkg/go.sum @@ -0,0 +1,2 @@ +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= diff --git a/testdata/fixtures/v2/aborted-cancel.json b/testdata/fixtures/v2/aborted-cancel.json new file mode 100644 index 0000000..fdb90c1 --- /dev/null +++ b/testdata/fixtures/v2/aborted-cancel.json @@ -0,0 +1,19 @@ +{ + "schema_version": "2.0", + "event_id": "00000000-0000-4000-8000-000000000004", + "ts_start": "2026-04-25T14:03:00.000Z", + "ts_end": "2026-04-25T14:03:00.030Z", + "duration_ms": 30, + "kind": "http", + "service": "checkout", + "env": "test", + "trace_id": "44444444444444444444444444444444", + "span_id": "5555555555555555", + "parent_span_id": "", + "status": "aborted", + "anchor": { "step": "request", "error_code": "WAYLOG_ABORTED" }, + "steps": [], + "logs": [], + "fields": { "http": { "method": "POST", "route": "/checkout" } }, + "errors": [] +} diff --git a/testdata/fixtures/v2/error-panic.json b/testdata/fixtures/v2/error-panic.json new file mode 100644 index 0000000..5748a4c --- /dev/null +++ b/testdata/fixtures/v2/error-panic.json @@ -0,0 +1,19 @@ +{ + "schema_version": "2.0", + "event_id": "00000000-0000-4000-8000-000000000006", + "ts_start": "2026-04-25T14:05:00.000Z", + "ts_end": "2026-04-25T14:05:00.015Z", + "duration_ms": 15, + "kind": "http", + "service": "checkout", + "env": "test", + "trace_id": "66666666666666666666666666666666", + "span_id": "7777777777777777", + "parent_span_id": "", + "status": "error", + "anchor": { "step": "request", "error_code": "WAYLOG_PANIC" }, + "steps": [], + "logs": [], + "fields": { "http": { "method": "POST", "route": "/checkout", "status": 500 } }, + "errors": [{ "code": "WAYLOG_PANIC", "reason": "runtime panic recovered" }] +} diff --git a/testdata/fixtures/v2/error-payment-cascade.json b/testdata/fixtures/v2/error-payment-cascade.json new file mode 100644 index 0000000..8d93346 --- /dev/null +++ b/testdata/fixtures/v2/error-payment-cascade.json @@ -0,0 +1,32 @@ +{ + "schema_version": "2.0", + "event_id": "00000000-0000-4000-8000-000000000002", + "ts_start": "2026-04-25T14:01:00.000Z", + "ts_end": "2026-04-25T14:01:00.120Z", + "duration_ms": 120, + "kind": "http", + "service": "checkout", + "env": "test", + "trace_id": "22222222222222222222222222222222", + "span_id": "2222222222222222", + "parent_span_id": "", + "status": "error", + "anchor": { "step": "payment.charge", "error_code": "PMT_502" }, + "steps": [ + { "name": "db.load_cart", "start_ms": 0, "duration_ms": 12, "status": "ok" }, + { + "name": "payment.charge", + "span_id": "3333333333333333", + "start_ms": 18, + "duration_ms": 90, + "status": "error", + "downstream": { "service": "payment", "endpoint": "POST /charge", "kind": "rpc" }, + "error": { "code": "PMT_502", "reason": "upstream gateway 5xx" } + } + ], + "logs": [ + { "ts_offset_ms": 22, "level": "warn", "msg": "retrying payment" } + ], + "fields": { "http": { "method": "POST", "route": "/checkout", "status": 502 }, "user": { "id": "u_123" } }, + "errors": [{ "code": "PMT_502", "reason": "upstream gateway 5xx" }] +} diff --git a/testdata/fixtures/v2/ok-simple.json b/testdata/fixtures/v2/ok-simple.json new file mode 100644 index 0000000..fed129f --- /dev/null +++ b/testdata/fixtures/v2/ok-simple.json @@ -0,0 +1,20 @@ +{ + "schema_version": "2.0", + "event_id": "00000000-0000-4000-8000-000000000001", + "ts_start": "2026-04-25T14:00:00.000Z", + "ts_end": "2026-04-25T14:00:00.010Z", + "duration_ms": 10, + "kind": "http", + "service": "checkout", + "env": "test", + "trace_id": "11111111111111111111111111111111", + "span_id": "1111111111111111", + "parent_span_id": "", + "status": "ok", + "steps": [ + { "name": "db.load_cart", "start_ms": 0, "duration_ms": 4, "status": "ok" } + ], + "logs": [], + "fields": { "http": { "method": "POST", "route": "/checkout", "status": 200 } }, + "errors": [] +} diff --git a/testdata/fixtures/v2/suppressed-healthcheck.json b/testdata/fixtures/v2/suppressed-healthcheck.json new file mode 100644 index 0000000..6a89108 --- /dev/null +++ b/testdata/fixtures/v2/suppressed-healthcheck.json @@ -0,0 +1,18 @@ +{ + "schema_version": "2.0", + "event_id": "00000000-0000-4000-8000-000000000005", + "ts_start": "2026-04-25T14:04:00.000Z", + "ts_end": "2026-04-25T14:04:00.002Z", + "duration_ms": 2, + "kind": "http", + "service": "checkout", + "env": "test", + "trace_id": "55555555555555555555555555555555", + "span_id": "6666666666666666", + "parent_span_id": "", + "status": "suppressed", + "steps": [], + "logs": [], + "fields": { "http": { "method": "GET", "route": "/healthz", "status": 200 } }, + "errors": [] +} diff --git a/testdata/fixtures/v2/timeout-watchdog.json b/testdata/fixtures/v2/timeout-watchdog.json new file mode 100644 index 0000000..59b59c1 --- /dev/null +++ b/testdata/fixtures/v2/timeout-watchdog.json @@ -0,0 +1,22 @@ +{ + "schema_version": "2.0", + "event_id": "00000000-0000-4000-8000-000000000003", + "ts_start": "2026-04-25T14:02:00.000Z", + "ts_end": "2026-04-25T14:02:05.000Z", + "duration_ms": 5000, + "kind": "http", + "service": "checkout", + "env": "test", + "trace_id": "33333333333333333333333333333333", + "span_id": "4444444444444444", + "parent_span_id": "", + "status": "timeout", + "anchor": { "step": "payment.charge", "error_code": "WAYLOG_TIMEOUT" }, + "steps": [ + { "name": "db.load_cart", "start_ms": 0, "duration_ms": 8, "status": "ok" }, + { "name": "payment.charge", "start_ms": 10, "duration_ms": 4980, "status": "ok" } + ], + "logs": [], + "fields": { "http": { "method": "POST", "route": "/checkout" } }, + "errors": [] +}