diff --git a/.github/workflows/graphqlmetrics-ci.yaml b/.github/workflows/graphqlmetrics-ci.yaml index 8ac7b02821..803065be39 100644 --- a/.github/workflows/graphqlmetrics-ci.yaml +++ b/.github/workflows/graphqlmetrics-ci.yaml @@ -3,6 +3,7 @@ on: pull_request: paths: - "graphqlmetrics/**/*" + - "proto/wg/cosmo/cacheevents/**/*" - ".github/workflows/graphqlmetrics-ci.yaml" concurrency: @@ -44,7 +45,7 @@ jobs: run: make setup-build-tools - name: Generate code - run: rm -rf graphqlmetrics/gen && buf generate --path proto/wg/cosmo/graphqlmetrics --path proto/wg/cosmo/common --template buf.graphqlmetrics.go.gen.yaml + run: rm -rf graphqlmetrics/gen && buf generate --path proto/wg/cosmo/graphqlmetrics --path proto/wg/cosmo/common --path proto/wg/cosmo/cacheevents --template buf.graphqlmetrics.go.gen.yaml - uses: ./.github/actions/git-dirty-check with: diff --git a/Makefile b/Makefile index ecd6bbd2d7..bd7c1ddeb5 100644 --- a/Makefile +++ b/Makefile @@ -116,8 +116,8 @@ generate: make generate-go generate-go: - rm -rf router/gen && buf generate --path proto/wg/cosmo/node --path proto/wg/cosmo/common --path proto/wg/cosmo/graphqlmetrics --template buf.router.go.gen.yaml - rm -rf graphqlmetrics/gen && buf generate --path proto/wg/cosmo/graphqlmetrics --path proto/wg/cosmo/common --template buf.graphqlmetrics.go.gen.yaml + rm -rf router/gen && buf generate --path proto/wg/cosmo/node --path proto/wg/cosmo/common --path proto/wg/cosmo/graphqlmetrics --path proto/wg/cosmo/cacheevents --template buf.router.go.gen.yaml + rm -rf graphqlmetrics/gen && buf generate --path proto/wg/cosmo/graphqlmetrics --path proto/wg/cosmo/common --path proto/wg/cosmo/cacheevents --template buf.graphqlmetrics.go.gen.yaml rm -rf connect-go/wg && buf generate --path proto/wg/cosmo/platform --path proto/wg/cosmo/notifications --path proto/wg/cosmo/common --path proto/wg/cosmo/node --template buf.connect-go.go.gen.yaml start-cp: diff --git a/controlplane/clickhouse/migrations/20260427120000_create_gql_cache_events.sql b/controlplane/clickhouse/migrations/20260427120000_create_gql_cache_events.sql new file mode 100644 index 0000000000..c37459b90d --- /dev/null +++ b/controlplane/clickhouse/migrations/20260427120000_create_gql_cache_events.sql @@ -0,0 +1,95 @@ +-- migrate:up + +CREATE TABLE IF NOT EXISTS gql_cache_events_raw +( + -- See https://github.com/PostHog/posthog/issues/10616 why ZSTD(3) is used + Timestamp DateTime64(9, 'UTC') CODEC(Delta, ZSTD(3)), + + -- Tenant + OrganizationID LowCardinality(String) CODEC(ZSTD(3)), + FederatedGraphID LowCardinality(String) CODEC(ZSTD(3)), + RouterConfigVersion LowCardinality(String) CODEC(ZSTD(3)), + + -- Event discriminator. Canonical lowercase string. Values: + -- 'l1_read','l2_read','l1_write','l2_write','fetch_timing', + -- 'subgraph_error','shadow_comparison','mutation','header_impact', + -- 'cache_op_error' + EventType LowCardinality(String) CODEC(ZSTD(3)), + + -- Operation context + OperationHash LowCardinality(String) CODEC(ZSTD(3)), + OperationName LowCardinality(String) CODEC(ZSTD(3)), + OperationType LowCardinality(String) CODEC(ZSTD(3)), + ClientName LowCardinality(String) CODEC(ZSTD(3)), + ClientVersion LowCardinality(String) CODEC(ZSTD(3)), + TraceID String CODEC(ZSTD(3)), + IsShadow Bool CODEC(ZSTD(3)), + + -- Cache identity + EntityType LowCardinality(String) CODEC(ZSTD(3)), + SubgraphID LowCardinality(String) CODEC(ZSTD(3)), + KeyHash UInt64 CODEC(ZSTD(3)), + + -- Field-level identity (root field for entity fetches; nested fields for value-type traversal) + FieldName LowCardinality(String) CODEC(ZSTD(3)), + FieldHash UInt64 CODEC(ZSTD(3)), + FieldPath Array(LowCardinality(String)) CODEC(ZSTD(3)), + EntityCount UInt32 CODEC(ZSTD(3)), + EntityUniqueKeys UInt32 CODEC(ZSTD(3)), + + -- Read events (l1_read, l2_read) + Verdict LowCardinality(String) CODEC(ZSTD(3)), + ByteSize UInt32 CODEC(ZSTD(3)), + CacheAgeMs UInt32 CODEC(ZSTD(3)), + + -- Write events (l1_write, l2_write) + TTLMs UInt32 CODEC(ZSTD(3)), + WriteReason LowCardinality(String) CODEC(ZSTD(3)), + Source LowCardinality(String) CODEC(ZSTD(3)), + + -- Fetch timing + FetchSource LowCardinality(String) CODEC(ZSTD(3)), + DurationMs Float64 CODEC(ZSTD(3)), + TTFBMs Float64 CODEC(ZSTD(3)), + ItemCount UInt32 CODEC(ZSTD(3)), + IsEntityFetch Bool CODEC(ZSTD(3)), + HttpStatusCode UInt16 CODEC(ZSTD(3)), + ResponseBytes UInt32 CODEC(ZSTD(3)), + + -- Errors (subgraph_error, cache_op_error) + ErrorMessage String CODEC(ZSTD(3)), + ErrorCode LowCardinality(String) CODEC(ZSTD(3)), + CacheOp LowCardinality(String) CODEC(ZSTD(3)), + CacheName LowCardinality(String) CODEC(ZSTD(3)), + + -- Shadow + mutation share these columns + ShadowIsFresh Bool CODEC(ZSTD(3)), + CachedHash UInt64 CODEC(ZSTD(3)), + FreshHash UInt64 CODEC(ZSTD(3)), + CachedBytes UInt32 CODEC(ZSTD(3)), + FreshBytes UInt32 CODEC(ZSTD(3)), + ConfiguredTTLMs UInt32 CODEC(ZSTD(3)), + + -- Mutation + MutationRootField LowCardinality(String) CODEC(ZSTD(3)), + HadCachedValue Bool CODEC(ZSTD(3)), + IsStale Bool CODEC(ZSTD(3)), + + -- Header impact + BaseKeyHash UInt64 CODEC(ZSTD(3)), + HeaderHash UInt64 CODEC(ZSTD(3)), + ResponseHash UInt64 CODEC(ZSTD(3)), + + INDEX idx_op_hash OperationHash TYPE bloom_filter(0.001) GRANULARITY 1, + INDEX idx_entity EntityType TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_subgraph SubgraphID TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_key_hash KeyHash TYPE bloom_filter(0.001) GRANULARITY 1 +) + engine = MergeTree PARTITION BY toDate(Timestamp) + ORDER BY (OrganizationID, FederatedGraphID, EventType, OperationHash, EntityType, SubgraphID, toUnixTimestamp(Timestamp)) + TTL toDateTime(Timestamp) + toIntervalDay(7) + SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1, non_replicated_deduplication_window = 1000; + +-- migrate:down + +DROP TABLE IF EXISTS gql_cache_events_raw; diff --git a/graphqlmetrics/cacheevents/processor.go b/graphqlmetrics/cacheevents/processor.go new file mode 100644 index 0000000000..00ba467292 --- /dev/null +++ b/graphqlmetrics/cacheevents/processor.go @@ -0,0 +1,50 @@ +package cacheevents + +import ( + "time" + + cacheeventsv1 "github.com/wundergraph/cosmo/graphqlmetrics/gen/proto/wg/cosmo/cacheevents/v1" + utils "github.com/wundergraph/cosmo/graphqlmetrics/pkg/utils" +) + +// BatchItem is the unit of work pushed onto the cache-events batch processor. +// One BatchItem corresponds to one PublishEntityCacheEvents RPC call from +// a router; it carries the events and the JWT claims that authenticated +// the request. +type BatchItem struct { + Events []*cacheeventsv1.CacheEvent + Claims *utils.GraphAPITokenClaims +} + +// ProcessorConfig carries the tunables for the cache-events batch processor. +// Defaults are set higher than the schema-usage processor because cache +// events are 10-100x request volume. +type ProcessorConfig struct { + MaxBatchSize int + MaxQueueSize int + MaxWorkers int + Interval time.Duration +} + +// DefaultProcessorConfig returns the resource-isolated defaults used when no +// env-overrides are provided. These are intentionally separate from the +// schema-usage processor's defaults so a cache-events spike does not +// degrade schema-usage SLAs. +func DefaultProcessorConfig() ProcessorConfig { + return ProcessorConfig{ + MaxBatchSize: 8192, + MaxQueueSize: 131072, + MaxWorkers: 4, + Interval: 5 * time.Second, + } +} + +// batchCost returns the number of events in the batch — used by the +// generic batchprocessor as the cost function. +func batchCost(items []BatchItem) int { + n := 0 + for _, it := range items { + n += len(it.Events) + } + return n +} diff --git a/graphqlmetrics/cacheevents/processor_test.go b/graphqlmetrics/cacheevents/processor_test.go new file mode 100644 index 0000000000..7a1a010fb3 --- /dev/null +++ b/graphqlmetrics/cacheevents/processor_test.go @@ -0,0 +1,46 @@ +package cacheevents + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + cacheeventsv1 "github.com/wundergraph/cosmo/graphqlmetrics/gen/proto/wg/cosmo/cacheevents/v1" +) + +func TestDefaultProcessorConfig(t *testing.T) { + t.Parallel() + + cfg := DefaultProcessorConfig() + require.Equal(t, 8192, cfg.MaxBatchSize) + require.Equal(t, 131072, cfg.MaxQueueSize) + require.Equal(t, 4, cfg.MaxWorkers) + require.Equal(t, 5*time.Second, cfg.Interval) +} + +func TestBatchCost(t *testing.T) { + t.Parallel() + + t.Run("nil slice has zero cost", func(t *testing.T) { + require.Equal(t, 0, batchCost(nil)) + }) + + t.Run("empty slice has zero cost", func(t *testing.T) { + require.Equal(t, 0, batchCost([]BatchItem{})) + }) + + t.Run("sums event counts across items", func(t *testing.T) { + items := []BatchItem{ + {Events: []*cacheeventsv1.CacheEvent{{}, {}, {}}}, + {Events: []*cacheeventsv1.CacheEvent{{}}}, + {Events: nil}, + {Events: []*cacheeventsv1.CacheEvent{{}, {}}}, + } + require.Equal(t, 6, batchCost(items)) + }) + + t.Run("item with nil events contributes zero", func(t *testing.T) { + items := []BatchItem{{Events: nil}, {Events: nil}} + require.Equal(t, 0, batchCost(items)) + }) +} diff --git a/graphqlmetrics/cacheevents/schema.go b/graphqlmetrics/cacheevents/schema.go new file mode 100644 index 0000000000..8063715849 --- /dev/null +++ b/graphqlmetrics/cacheevents/schema.go @@ -0,0 +1,88 @@ +package cacheevents + +import ( + cacheeventsv1 "github.com/wundergraph/cosmo/graphqlmetrics/gen/proto/wg/cosmo/cacheevents/v1" +) + +// EventTypeString returns the canonical lowercase string for the EventType +// LowCardinality column. Unknown values produce an empty string so they are +// pruned from the rollup MV (ClickHouse treats them as a single bucket). +func EventTypeString(t cacheeventsv1.EventType) string { + switch t { + case cacheeventsv1.EventType_L1_READ: + return "l1_read" + case cacheeventsv1.EventType_L2_READ: + return "l2_read" + case cacheeventsv1.EventType_L1_WRITE: + return "l1_write" + case cacheeventsv1.EventType_L2_WRITE: + return "l2_write" + case cacheeventsv1.EventType_FETCH_TIMING: + return "fetch_timing" + case cacheeventsv1.EventType_SUBGRAPH_ERROR: + return "subgraph_error" + case cacheeventsv1.EventType_SHADOW_COMPARISON: + return "shadow_comparison" + case cacheeventsv1.EventType_MUTATION: + return "mutation" + case cacheeventsv1.EventType_HEADER_IMPACT: + return "header_impact" + case cacheeventsv1.EventType_CACHE_OP_ERROR: + return "cache_op_error" + case cacheeventsv1.EventType_FIELD_HASH: + return "field_hash" + case cacheeventsv1.EventType_ENTITY_TYPE_INFO: + return "entity_type_info" + default: + return "" + } +} + +// CacheOpKindString returns the canonical lowercase name for the new CacheOpKind +// proto enum, matching the existing freeform cache_op string values. +func CacheOpKindString(k cacheeventsv1.CacheOpKind) string { + switch k { + case cacheeventsv1.CacheOpKind_GET: + return "get" + case cacheeventsv1.CacheOpKind_SET: + return "set" + case cacheeventsv1.CacheOpKind_SET_NEGATIVE: + return "set_negative" + case cacheeventsv1.CacheOpKind_DELETE: + return "delete" + default: + return "" + } +} + +func VerdictString(v cacheeventsv1.Verdict) string { + switch v { + case cacheeventsv1.Verdict_HIT: + return "hit" + case cacheeventsv1.Verdict_MISS: + return "miss" + case cacheeventsv1.Verdict_PARTIAL_HIT: + return "partial_hit" + case cacheeventsv1.Verdict_FRESH: + return "fresh" + case cacheeventsv1.Verdict_STALE: + return "stale" + default: + return "" + } +} + +func FieldSourceString(s cacheeventsv1.FieldSource) string { + switch s { + case cacheeventsv1.FieldSource_SUBGRAPH: + return "subgraph" + case cacheeventsv1.FieldSource_L1: + return "l1" + case cacheeventsv1.FieldSource_L2: + return "l2" + case cacheeventsv1.FieldSource_SHADOW_CACHED: + return "shadow_cached" + default: + return "" + } +} diff --git a/graphqlmetrics/cacheevents/schema_test.go b/graphqlmetrics/cacheevents/schema_test.go new file mode 100644 index 0000000000..1b7355e613 --- /dev/null +++ b/graphqlmetrics/cacheevents/schema_test.go @@ -0,0 +1,88 @@ +package cacheevents + +import ( + "testing" + + "github.com/stretchr/testify/require" + cacheeventsv1 "github.com/wundergraph/cosmo/graphqlmetrics/gen/proto/wg/cosmo/cacheevents/v1" +) + +func TestEventTypeString(t *testing.T) { + t.Parallel() + + cases := map[cacheeventsv1.EventType]string{ + cacheeventsv1.EventType_L1_READ: "l1_read", + cacheeventsv1.EventType_L2_READ: "l2_read", + cacheeventsv1.EventType_L1_WRITE: "l1_write", + cacheeventsv1.EventType_L2_WRITE: "l2_write", + cacheeventsv1.EventType_FETCH_TIMING: "fetch_timing", + cacheeventsv1.EventType_SUBGRAPH_ERROR: "subgraph_error", + cacheeventsv1.EventType_SHADOW_COMPARISON: "shadow_comparison", + cacheeventsv1.EventType_MUTATION: "mutation", + cacheeventsv1.EventType_HEADER_IMPACT: "header_impact", + cacheeventsv1.EventType_CACHE_OP_ERROR: "cache_op_error", + cacheeventsv1.EventType_FIELD_HASH: "field_hash", + cacheeventsv1.EventType_ENTITY_TYPE_INFO: "entity_type_info", + } + for code, want := range cases { + require.Equalf(t, want, EventTypeString(code), "EventType=%s", code) + } + + // UNSPECIFIED and any unknown integer value must produce "" so the + // rollup MV groups them into a single bucket rather than churning the + // LowCardinality dictionary. + require.Empty(t, EventTypeString(cacheeventsv1.EventType_EVENT_TYPE_UNSPECIFIED)) + require.Empty(t, EventTypeString(cacheeventsv1.EventType(9999))) +} + +func TestCacheOpKindString(t *testing.T) { + t.Parallel() + + cases := map[cacheeventsv1.CacheOpKind]string{ + cacheeventsv1.CacheOpKind_GET: "get", + cacheeventsv1.CacheOpKind_SET: "set", + cacheeventsv1.CacheOpKind_SET_NEGATIVE: "set_negative", + cacheeventsv1.CacheOpKind_DELETE: "delete", + } + for code, want := range cases { + require.Equalf(t, want, CacheOpKindString(code), "CacheOpKind=%s", code) + } + + require.Empty(t, CacheOpKindString(cacheeventsv1.CacheOpKind_CACHE_OP_KIND_UNSPECIFIED)) + require.Empty(t, CacheOpKindString(cacheeventsv1.CacheOpKind(9999))) +} + +func TestVerdictString(t *testing.T) { + t.Parallel() + + cases := map[cacheeventsv1.Verdict]string{ + cacheeventsv1.Verdict_HIT: "hit", + cacheeventsv1.Verdict_MISS: "miss", + cacheeventsv1.Verdict_PARTIAL_HIT: "partial_hit", + cacheeventsv1.Verdict_FRESH: "fresh", + cacheeventsv1.Verdict_STALE: "stale", + } + for code, want := range cases { + require.Equalf(t, want, VerdictString(code), "Verdict=%s", code) + } + + require.Empty(t, VerdictString(cacheeventsv1.Verdict_VERDICT_UNSPECIFIED)) + require.Empty(t, VerdictString(cacheeventsv1.Verdict(9999))) +} + +func TestFieldSourceString(t *testing.T) { + t.Parallel() + + cases := map[cacheeventsv1.FieldSource]string{ + cacheeventsv1.FieldSource_SUBGRAPH: "subgraph", + cacheeventsv1.FieldSource_L1: "l1", + cacheeventsv1.FieldSource_L2: "l2", + cacheeventsv1.FieldSource_SHADOW_CACHED: "shadow_cached", + } + for code, want := range cases { + require.Equalf(t, want, FieldSourceString(code), "FieldSource=%s", code) + } + + require.Empty(t, FieldSourceString(cacheeventsv1.FieldSource_FIELD_SOURCE_UNSPECIFIED)) + require.Empty(t, FieldSourceString(cacheeventsv1.FieldSource(9999))) +} diff --git a/graphqlmetrics/cacheevents/service.go b/graphqlmetrics/cacheevents/service.go new file mode 100644 index 0000000000..691124178a --- /dev/null +++ b/graphqlmetrics/cacheevents/service.go @@ -0,0 +1,79 @@ +package cacheevents + +import ( + "context" + "errors" + "time" + + "connectrpc.com/connect" + "github.com/ClickHouse/clickhouse-go/v2" + cacheeventsv1 "github.com/wundergraph/cosmo/graphqlmetrics/gen/proto/wg/cosmo/cacheevents/v1" + "github.com/wundergraph/cosmo/graphqlmetrics/pkg/batchprocessor" + utils "github.com/wundergraph/cosmo/graphqlmetrics/pkg/utils" + "go.uber.org/zap" +) + +var errNotAuthenticated = errors.New("authentication didn't succeed") + +// Service is the Connect handler for CacheEventsService. It owns its own +// BatchProcessor instance, separate from the schema-usage path, so a +// cache-events spike does not block schema-usage ingestion. +type Service struct { + logger *zap.Logger + processor *batchprocessor.BatchProcessor[BatchItem] +} + +// NewService constructs the Connect handler. It wires a fresh ClickHouse +// writer + batch processor sized for high-volume per-fetch events. +func NewService(logger *zap.Logger, conn clickhouse.Conn, cfg ProcessorConfig) *Service { + writer := NewWriter(logger, conn) + + bp := batchprocessor.New(batchprocessor.Options[BatchItem]{ + MaxQueueSize: cfg.MaxQueueSize, + CostFunc: batchCost, + CostThreshold: cfg.MaxBatchSize, + Interval: cfg.Interval, + MaxWorkers: cfg.MaxWorkers, + Dispatcher: writer.ProcessBatch, + }) + + return &Service{ + logger: logger, + processor: bp, + } +} + +// PublishEntityCacheEvents accepts a batch of CacheEvent records, validates +// the JWT claims, and enqueues them for async ClickHouse ingestion. +func (s *Service) PublishEntityCacheEvents( + ctx context.Context, + req *connect.Request[cacheeventsv1.PublishEntityCacheEventsRequest], +) (*connect.Response[cacheeventsv1.PublishEntityCacheEventsResponse], error) { + res := connect.NewResponse(&cacheeventsv1.PublishEntityCacheEventsResponse{}) + + claims, err := utils.GetClaims(ctx) + if err != nil { + return nil, connect.NewError(connect.CodeUnauthenticated, errNotAuthenticated) + } + + if len(req.Msg.Events) == 0 { + return res, nil + } + + if err := s.processor.Push(BatchItem{ + Events: req.Msg.Events, + Claims: claims, + }); err != nil { + s.logger.Warn("Cache events queue rejected push", zap.Error(err)) + return nil, connect.NewError(connect.CodeResourceExhausted, err) + } + + return res, nil +} + +// Shutdown drains the in-flight batch processor. +func (s *Service) Shutdown(timeout time.Duration) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + _ = s.processor.StopAndWait(ctx) +} diff --git a/graphqlmetrics/cacheevents/service_test.go b/graphqlmetrics/cacheevents/service_test.go new file mode 100644 index 0000000000..4e11a1bc2a --- /dev/null +++ b/graphqlmetrics/cacheevents/service_test.go @@ -0,0 +1,168 @@ +package cacheevents + +import ( + "context" + "sync" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/stretchr/testify/require" + cacheeventsv1 "github.com/wundergraph/cosmo/graphqlmetrics/gen/proto/wg/cosmo/cacheevents/v1" + "github.com/wundergraph/cosmo/graphqlmetrics/pkg/batchprocessor" + utils "github.com/wundergraph/cosmo/graphqlmetrics/pkg/utils" + "go.uber.org/zap" +) + +// recordingDispatcher captures every batch the BatchProcessor flushes so +// tests can assert on enqueue + dispatch behaviour without ClickHouse. +type recordingDispatcher struct { + mu sync.Mutex + batches [][]BatchItem +} + +func (r *recordingDispatcher) dispatch(_ context.Context, items []BatchItem) { + r.mu.Lock() + // Copy because the BatchProcessor reuses the underlying buffer. + cp := make([]BatchItem, len(items)) + copy(cp, items) + r.batches = append(r.batches, cp) + r.mu.Unlock() +} + +func (r *recordingDispatcher) snapshot() [][]BatchItem { + r.mu.Lock() + defer r.mu.Unlock() + out := make([][]BatchItem, len(r.batches)) + copy(out, r.batches) + return out +} + +// newTestService builds a Service whose batch processor flushes through a +// recording dispatcher instead of writing to ClickHouse. The threshold is 1 +// so each push triggers an immediate flush. +func newTestService(t *testing.T) (*Service, *recordingDispatcher) { + t.Helper() + rec := &recordingDispatcher{} + bp := batchprocessor.New(batchprocessor.Options[BatchItem]{ + MaxQueueSize: 64, + CostFunc: batchCost, + CostThreshold: 1, + Interval: 50 * time.Millisecond, + MaxWorkers: 1, + Dispatcher: rec.dispatch, + }) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = bp.StopAndWait(ctx) + }) + return &Service{logger: zap.NewNop(), processor: bp}, rec +} + +func TestService_PublishEntityCacheEvents_RequiresAuth(t *testing.T) { + t.Parallel() + svc, rec := newTestService(t) + + // No claims attached to the context. + resp, err := svc.PublishEntityCacheEvents( + context.Background(), + connect.NewRequest(&cacheeventsv1.PublishEntityCacheEventsRequest{ + Events: []*cacheeventsv1.CacheEvent{{EventType: cacheeventsv1.EventType_L1_READ}}, + }), + ) + require.ErrorIs(t, err, errNotAuthenticated) + require.Nil(t, resp) + + // Give the processor a moment to (not) dispatch. + time.Sleep(100 * time.Millisecond) + require.Empty(t, rec.snapshot(), "auth-rejected requests must not enqueue events") +} + +func TestService_PublishEntityCacheEvents_EmptyEventsIsNoOp(t *testing.T) { + t.Parallel() + svc, rec := newTestService(t) + + ctx := utils.SetClaims(context.Background(), &utils.GraphAPITokenClaims{ + OrganizationID: "org-1", + FederatedGraphID: "fg-1", + }) + + resp, err := svc.PublishEntityCacheEvents( + ctx, + connect.NewRequest(&cacheeventsv1.PublishEntityCacheEventsRequest{}), + ) + require.NoError(t, err) + require.NotNil(t, resp) + + time.Sleep(100 * time.Millisecond) + require.Empty(t, rec.snapshot(), "empty-events requests must not enqueue") +} + +func TestService_PublishEntityCacheEvents_EnqueuesWithClaims(t *testing.T) { + t.Parallel() + svc, rec := newTestService(t) + + claims := &utils.GraphAPITokenClaims{ + OrganizationID: "org-42", + FederatedGraphID: "fg-42", + } + ctx := utils.SetClaims(context.Background(), claims) + + events := []*cacheeventsv1.CacheEvent{ + {EventType: cacheeventsv1.EventType_L1_READ, EntityType: "User"}, + {EventType: cacheeventsv1.EventType_L2_WRITE, EntityType: "Product"}, + } + resp, err := svc.PublishEntityCacheEvents( + ctx, + connect.NewRequest(&cacheeventsv1.PublishEntityCacheEventsRequest{Events: events}), + ) + require.NoError(t, err) + require.NotNil(t, resp) + + require.Eventually(t, func() bool { + for _, batch := range rec.snapshot() { + for _, item := range batch { + if len(item.Events) == len(events) && item.Claims != nil && + item.Claims.OrganizationID == claims.OrganizationID && + item.Claims.FederatedGraphID == claims.FederatedGraphID { + return true + } + } + } + return false + }, 2*time.Second, 25*time.Millisecond, "expected dispatcher to receive the published batch with attached claims") +} + +// TestService_Shutdown_ReturnsWithinTimeout verifies that Shutdown returns +// promptly when the processor is idle. Drain-after-push semantics depend on +// the BatchProcessor's manager goroutine being scheduled before doneChan is +// closed, so they're covered indirectly by +// TestService_PublishEntityCacheEvents_EnqueuesWithClaims (which uses +// require.Eventually) rather than asserted strictly here. +func TestService_Shutdown_ReturnsWithinTimeout(t *testing.T) { + t.Parallel() + + rec := &recordingDispatcher{} + bp := batchprocessor.New(batchprocessor.Options[BatchItem]{ + MaxQueueSize: 64, + CostFunc: batchCost, + CostThreshold: 1, + Interval: 50 * time.Millisecond, + MaxWorkers: 1, + Dispatcher: rec.dispatch, + }) + svc := &Service{logger: zap.NewNop(), processor: bp} + + done := make(chan struct{}) + go func() { + svc.Shutdown(2 * time.Second) + close(done) + }() + + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("Shutdown did not return within the configured timeout") + } +} diff --git a/graphqlmetrics/cacheevents/writer.go b/graphqlmetrics/cacheevents/writer.go new file mode 100644 index 0000000000..a3e44b7123 --- /dev/null +++ b/graphqlmetrics/cacheevents/writer.go @@ -0,0 +1,183 @@ +package cacheevents + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/ClickHouse/clickhouse-go/v2" + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" + "github.com/avast/retry-go" + "github.com/google/uuid" + cacheeventsv1 "github.com/wundergraph/cosmo/graphqlmetrics/gen/proto/wg/cosmo/cacheevents/v1" + "go.uber.org/zap" +) + +const insertCacheEventsRawQuery = `INSERT INTO gql_cache_events_raw` + +// Writer ships batches of cache events to ClickHouse. +type Writer struct { + logger *zap.Logger + conn clickhouse.Conn +} + +// NewWriter constructs a Writer bound to the given ClickHouse connection. +func NewWriter(logger *zap.Logger, conn clickhouse.Conn) *Writer { + return &Writer{ + logger: logger, + conn: conn, + } +} + +// ProcessBatch is the dispatcher passed to the batchprocessor. It opens a +// single ClickHouse batch, appends one row per CacheEvent across all items +// in the input slice, then sends the batch with retry. Errors are logged +// but never returned: the batch processor has no error channel. +func (w *Writer) ProcessBatch(ctx context.Context, items []BatchItem) { + if len(items) == 0 { + return + } + + insertTime := time.Now().UTC() + + // Stable per-logical-batch deduplication token reused across retries. + // clickhouse-go v2 marks a batch "sent" even when Send() returns an + // error, so the only safe retry is to build a fresh batch — which would + // duplicate rows if ClickHouse had already committed the first attempt. + // insert_deduplication_token tells the server to drop matching repeats. + dedupCtx := clickhouse.Context(ctx, clickhouse.WithSettings(clickhouse.Settings{ + "insert_deduplicate": uint8(1), + "insert_deduplication_token": uuid.NewString(), + })) + + err := retry.Do( + func() error { + batch, err := w.conn.PrepareBatch(dedupCtx, insertCacheEventsRawQuery) + if err != nil { + return fmt.Errorf("prepare batch: %w", err) + } + + rows := 0 + for _, item := range items { + if item.Claims == nil { + continue + } + for _, ev := range item.Events { + if ev == nil { + continue + } + if err := appendCacheEventRow(batch, insertTime, item.Claims.OrganizationID, item.Claims.FederatedGraphID, ev); err != nil { + w.logger.Error("Failed to append cache event row", zap.Error(err)) + return fmt.Errorf("append cache event row: %w", err) + } + rows++ + } + } + + if rows == 0 { + _ = batch.Abort() + return nil + } + + if err := batch.Send(); err != nil { + return fmt.Errorf("send batch: %w", err) + } + + w.logger.Debug("Cache events batch sent", zap.Int("rows", rows)) + return nil + }, + retry.Attempts(3), + retry.Delay(100*time.Millisecond), + retry.MaxDelay(1*time.Second), + retry.DelayType(retry.BackOffDelay), + retry.Context(ctx), + ) + if err != nil { + w.logger.Error("Failed to flush cache events batch after retries", zap.Error(err)) + } +} + +// appendCacheEventRow appends one row to the ClickHouse batch in the same +// column order as the gql_cache_events_raw migration. +func appendCacheEventRow( + batch driver.Batch, + insertTime time.Time, + organizationID, federatedGraphID string, + ev *cacheeventsv1.CacheEvent, +) error { + ts := insertTime + if ev.TimestampUnixNano != 0 { + ts = time.Unix(0, int64(ev.TimestampUnixNano)).UTC() + } + + // Prefer the typed CacheOpKind enum when set; fall back to the legacy + // freeform string for backward compatibility with older routers. + cacheOp := ev.CacheOp + if ev.CacheOpKind != cacheeventsv1.CacheOpKind_CACHE_OP_KIND_UNSPECIFIED { + cacheOp = CacheOpKindString(ev.CacheOpKind) + } + + return batch.Append( + ts, // Timestamp + organizationID, // OrganizationID + federatedGraphID, // FederatedGraphID + ev.RouterConfigVersion, // RouterConfigVersion + EventTypeString(ev.EventType), // EventType + ev.OperationHash, // OperationHash + ev.OperationName, // OperationName + strings.ToLower(ev.OperationType), // OperationType + ev.ClientName, // ClientName + ev.ClientVersion, // ClientVersion + ev.TraceId, // TraceID + ev.IsShadow, // IsShadow + ev.EntityType, // EntityType + ev.SubgraphId, // SubgraphID + ev.KeyHash, // KeyHash + ev.FieldName, // FieldName + ev.FieldHash, // FieldHash + fieldPathColumn(ev.FieldPath), // FieldPath (Array(LowCardinality(String))) + ev.EntityCount, // EntityCount + ev.EntityUniqueKeys, // EntityUniqueKeys + VerdictString(ev.Verdict), // Verdict + ev.ByteSize, // ByteSize + ev.CacheAgeMs, // CacheAgeMs + ev.TtlMs, // TTLMs + ev.WriteReason, // WriteReason + ev.Source, // Source + FieldSourceString(ev.FetchSource), // FetchSource + ev.DurationMs, // DurationMs + ev.TtfbMs, // TTFBMs + ev.ItemCount, // ItemCount + ev.IsEntityFetch, // IsEntityFetch + uint16(ev.HttpStatusCode), // HttpStatusCode + ev.ResponseBytes, // ResponseBytes + ev.ErrorMessage, // ErrorMessage + ev.ErrorCode, // ErrorCode + cacheOp, // CacheOp + ev.CacheName, // CacheName + ev.ShadowIsFresh, // ShadowIsFresh + ev.CachedHash, // CachedHash + ev.FreshHash, // FreshHash + ev.CachedBytes, // CachedBytes + ev.FreshBytes, // FreshBytes + ev.ConfiguredTtlMs, // ConfiguredTTLMs + ev.MutationRootField, // MutationRootField + ev.HadCachedValue, // HadCachedValue + ev.IsStale, // IsStale + ev.BaseKeyHash, // BaseKeyHash + ev.HeaderHash, // HeaderHash + ev.ResponseHash, // ResponseHash + ) +} + +// fieldPathColumn normalizes a possibly-nil FieldPath onto the empty slice the +// ClickHouse driver expects for an Array column. Empty slice marks a direct +// entity scalar (no value-type traversal). Older routers that don't populate +// FieldPath will arrive nil and serialize as []string{}. +func fieldPathColumn(p []string) []string { + if len(p) == 0 { + return []string{} + } + return p +} diff --git a/graphqlmetrics/cacheevents/writer_test.go b/graphqlmetrics/cacheevents/writer_test.go new file mode 100644 index 0000000000..082ff73fa2 --- /dev/null +++ b/graphqlmetrics/cacheevents/writer_test.go @@ -0,0 +1,343 @@ +package cacheevents + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/ClickHouse/clickhouse-go/v2/lib/column" + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" + "github.com/stretchr/testify/require" + cacheeventsv1 "github.com/wundergraph/cosmo/graphqlmetrics/gen/proto/wg/cosmo/cacheevents/v1" + utils "github.com/wundergraph/cosmo/graphqlmetrics/pkg/utils" + "go.uber.org/zap" +) + +func TestFieldPathColumn(t *testing.T) { + t.Parallel() + + t.Run("nil normalizes to empty slice", func(t *testing.T) { + got := fieldPathColumn(nil) + require.NotNil(t, got, "ClickHouse Array column requires non-nil empty slice") + require.Empty(t, got) + }) + + t.Run("empty slice stays empty", func(t *testing.T) { + got := fieldPathColumn([]string{}) + require.NotNil(t, got) + require.Empty(t, got) + }) + + t.Run("non-empty slice passes through unchanged", func(t *testing.T) { + in := []string{"user", "address", "city"} + require.Equal(t, in, fieldPathColumn(in)) + }) + + t.Run("single-element slice passes through", func(t *testing.T) { + require.Equal(t, []string{"name"}, fieldPathColumn([]string{"name"})) + }) +} + +// fakeBatch is a driver.Batch test double that records every Append call. +// Only Append/Send/Abort are exercised by the writer; the other interface +// methods exist solely to satisfy the type. +type fakeBatch struct { + mu sync.Mutex + rows [][]any + sendCalls int + abortCs int + sendErr error +} + +func (b *fakeBatch) Append(v ...any) error { + b.mu.Lock() + defer b.mu.Unlock() + row := make([]any, len(v)) + copy(row, v) + b.rows = append(b.rows, row) + return nil +} + +func (b *fakeBatch) Send() error { + b.mu.Lock() + defer b.mu.Unlock() + b.sendCalls++ + return b.sendErr +} + +func (b *fakeBatch) Abort() error { + b.mu.Lock() + defer b.mu.Unlock() + b.abortCs++ + return nil +} + +func (b *fakeBatch) AppendStruct(any) error { return errors.New("not implemented") } +func (b *fakeBatch) Column(int) driver.BatchColumn { return nil } +func (b *fakeBatch) Flush() error { return nil } +func (b *fakeBatch) IsSent() bool { return false } +func (b *fakeBatch) Rows() int { return len(b.rows) } +func (b *fakeBatch) Columns() []column.Interface { return nil } + +func TestAppendCacheEventRow_TimestampFallback(t *testing.T) { + t.Parallel() + + insert := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC) + + t.Run("event without TimestampUnixNano falls back to insertTime", func(t *testing.T) { + batch := &fakeBatch{} + ev := &cacheeventsv1.CacheEvent{EventType: cacheeventsv1.EventType_L1_READ} + require.NoError(t, appendCacheEventRow(batch, insert, "org", "fg", ev)) + require.Len(t, batch.rows, 1) + require.Equal(t, insert, batch.rows[0][0], "first column is Timestamp; must use insertTime when event timestamp is zero") + }) + + t.Run("event with TimestampUnixNano uses that exact instant in UTC", func(t *testing.T) { + batch := &fakeBatch{} + eventTime := time.Date(2026, 4, 1, 9, 30, 0, 12345, time.UTC) + ev := &cacheeventsv1.CacheEvent{ + EventType: cacheeventsv1.EventType_L1_WRITE, + TimestampUnixNano: uint64(eventTime.UnixNano()), + } + require.NoError(t, appendCacheEventRow(batch, insert, "org", "fg", ev)) + require.Len(t, batch.rows, 1) + got, ok := batch.rows[0][0].(time.Time) + require.True(t, ok) + require.Truef(t, eventTime.Equal(got), "event timestamp must round-trip: want %s got %s", eventTime, got) + require.Equal(t, time.UTC, got.Location(), "timestamp must be normalized to UTC") + }) +} + +func TestAppendCacheEventRow_CacheOpKindOverridesFreeformString(t *testing.T) { + t.Parallel() + + insert := time.Now().UTC() + + // CacheOp column is at a fixed position — find it by counting. + // Order from the source: [Timestamp, OrgID, FedGraphID, RouterConfigVersion, + // EventType, OperationHash, OperationName, OperationType, ClientName, + // ClientVersion, TraceID, IsShadow, EntityType, SubgraphID, KeyHash, + // Verdict, ByteSize, CacheAgeMs, TTLMs, WriteReason, Source, FetchSource, + // DurationMs, TTFBMs, ItemCount, IsEntityFetch, HttpStatusCode, + // ResponseBytes, ErrorMessage, ErrorCode, CacheOp, ...] + const cacheOpIdx = 30 + + t.Run("typed enum wins over the legacy string", func(t *testing.T) { + batch := &fakeBatch{} + ev := &cacheeventsv1.CacheEvent{ + EventType: cacheeventsv1.EventType_CACHE_OP_ERROR, + CacheOp: "this should be ignored", + CacheOpKind: cacheeventsv1.CacheOpKind_DELETE, + } + require.NoError(t, appendCacheEventRow(batch, insert, "org", "fg", ev)) + require.Equal(t, "delete", batch.rows[0][cacheOpIdx]) + }) + + t.Run("freeform string is used when the enum is unspecified", func(t *testing.T) { + batch := &fakeBatch{} + ev := &cacheeventsv1.CacheEvent{ + EventType: cacheeventsv1.EventType_CACHE_OP_ERROR, + CacheOp: "legacy_value", + CacheOpKind: cacheeventsv1.CacheOpKind_CACHE_OP_KIND_UNSPECIFIED, + } + require.NoError(t, appendCacheEventRow(batch, insert, "org", "fg", ev)) + require.Equal(t, "legacy_value", batch.rows[0][cacheOpIdx], + "old routers populate CacheOp without CacheOpKind; the writer must preserve that path") + }) +} + +func TestAppendCacheEventRow_OperationTypeIsLowercased(t *testing.T) { + t.Parallel() + + const operationTypeIdx = 7 + + batch := &fakeBatch{} + require.NoError(t, appendCacheEventRow(batch, time.Now().UTC(), "o", "f", &cacheeventsv1.CacheEvent{ + OperationType: "MUTATION", + })) + require.Equal(t, "mutation", batch.rows[0][operationTypeIdx], + "OperationType normalization is the writer's last-line-of-defense — even if a router skips it, the column must stay lowercase") +} + +func TestAppendCacheEventRow_OrgAndGraphIDsArePropagated(t *testing.T) { + t.Parallel() + + const orgIdx, fgIdx = 1, 2 + + batch := &fakeBatch{} + require.NoError(t, appendCacheEventRow(batch, time.Now().UTC(), "org-7", "fg-9", &cacheeventsv1.CacheEvent{})) + require.Equal(t, "org-7", batch.rows[0][orgIdx]) + require.Equal(t, "fg-9", batch.rows[0][fgIdx]) +} + +func TestAppendCacheEventRow_FieldPath_ServializesAsArrayColumn(t *testing.T) { + t.Parallel() + + const fieldPathIdx = 41 + + t.Run("nil FieldPath becomes empty array, never nil", func(t *testing.T) { + batch := &fakeBatch{} + require.NoError(t, appendCacheEventRow(batch, time.Now().UTC(), "o", "f", &cacheeventsv1.CacheEvent{})) + got, ok := batch.rows[0][fieldPathIdx].([]string) + require.True(t, ok, "FieldPath column type must be []string") + require.NotNil(t, got, "ClickHouse Array column rejects untyped nil") + require.Empty(t, got) + }) + + t.Run("non-empty FieldPath round-trips", func(t *testing.T) { + batch := &fakeBatch{} + require.NoError(t, appendCacheEventRow(batch, time.Now().UTC(), "o", "f", &cacheeventsv1.CacheEvent{ + FieldPath: []string{"address", "city"}, + })) + require.Equal(t, []string{"address", "city"}, batch.rows[0][fieldPathIdx]) + }) +} + +// clickhouseConnStub satisfies driver.Conn with panicking methods so embedders +// only need to override what they exercise. PrepareBatch is the only call the +// writer makes; if a new dependency is added, the test will panic loudly. +type clickhouseConnStub struct{} + +func (clickhouseConnStub) Contributors() []string { panic("not implemented") } +func (clickhouseConnStub) ServerVersion() (*driver.ServerVersion, error) { panic("not implemented") } +func (clickhouseConnStub) Select(context.Context, any, string, ...any) error { + panic("not implemented") +} +func (clickhouseConnStub) Query(context.Context, string, ...any) (driver.Rows, error) { + panic("not implemented") +} +func (clickhouseConnStub) QueryRow(context.Context, string, ...any) driver.Row { + panic("not implemented") +} +func (clickhouseConnStub) PrepareBatch(context.Context, string, ...driver.PrepareBatchOption) (driver.Batch, error) { + panic("not implemented") +} +func (clickhouseConnStub) Exec(context.Context, string, ...any) error { panic("not implemented") } +func (clickhouseConnStub) AsyncInsert(context.Context, string, bool, ...any) error { + panic("not implemented") +} +func (clickhouseConnStub) Ping(context.Context) error { panic("not implemented") } +func (clickhouseConnStub) Stats() driver.Stats { panic("not implemented") } +func (clickhouseConnStub) Close() error { panic("not implemented") } + +// recordingConn is the minimal slice of the clickhouse.Conn surface that the +// writer's ProcessBatch path actually exercises. Only PrepareBatch is called +// — the rest of the interface intentionally panics so we notice when a new +// dependency creeps in. +type recordingConn struct { + clickhouseConnStub + mu sync.Mutex + batches []*fakeBatch + err error +} + +func (c *recordingConn) PrepareBatch(_ context.Context, _ string, _ ...driver.PrepareBatchOption) (driver.Batch, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.err != nil { + return nil, c.err + } + b := &fakeBatch{} + c.batches = append(c.batches, b) + return b, nil +} + +func (c *recordingConn) batchCount() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.batches) +} + +func (c *recordingConn) lastBatch() *fakeBatch { + c.mu.Lock() + defer c.mu.Unlock() + if len(c.batches) == 0 { + return nil + } + return c.batches[len(c.batches)-1] +} + +func TestWriter_ProcessBatch_EmptyItemsDoesNothing(t *testing.T) { + t.Parallel() + + conn := &recordingConn{} + w := NewWriter(zap.NewNop(), conn) + + w.ProcessBatch(context.Background(), nil) + w.ProcessBatch(context.Background(), []BatchItem{}) + + require.Equal(t, 0, conn.batchCount(), "no work means no PrepareBatch call") +} + +func TestWriter_ProcessBatch_DropsItemsWithNilClaims(t *testing.T) { + t.Parallel() + + conn := &recordingConn{} + w := NewWriter(zap.NewNop(), conn) + + w.ProcessBatch(context.Background(), []BatchItem{ + {Claims: nil, Events: []*cacheeventsv1.CacheEvent{{EventType: cacheeventsv1.EventType_L1_READ}}}, + }) + + // PrepareBatch is called speculatively, then the batch is aborted because + // no rows were appended (the writer cannot tell ClickHouse the rows are + // missing without first claiming a batch). + require.Equal(t, 1, conn.batchCount()) + require.Equal(t, 0, len(conn.lastBatch().rows), "no rows must be appended for unauthenticated items") + require.Equal(t, 1, conn.lastBatch().abortCs, "empty batches must be aborted, not sent") + require.Equal(t, 0, conn.lastBatch().sendCalls) +} + +func TestWriter_ProcessBatch_SkipsNilEvents(t *testing.T) { + t.Parallel() + + conn := &recordingConn{} + w := NewWriter(zap.NewNop(), conn) + + claims := &utils.GraphAPITokenClaims{OrganizationID: "org-1", FederatedGraphID: "fg-1"} + w.ProcessBatch(context.Background(), []BatchItem{ + { + Claims: claims, + Events: []*cacheeventsv1.CacheEvent{ + {EventType: cacheeventsv1.EventType_L1_READ, EntityType: "User"}, + nil, + {EventType: cacheeventsv1.EventType_L2_WRITE, EntityType: "Product"}, + }, + }, + }) + + require.Equal(t, 1, conn.batchCount()) + require.Equal(t, 2, conn.lastBatch().Rows(), "nil event entries must be skipped, not appended as zero-value rows") + require.Equal(t, 1, conn.lastBatch().sendCalls) +} + +func TestWriter_ProcessBatch_AppendsOneRowPerEventAcrossItems(t *testing.T) { + t.Parallel() + + conn := &recordingConn{} + w := NewWriter(zap.NewNop(), conn) + + w.ProcessBatch(context.Background(), []BatchItem{ + { + Claims: &utils.GraphAPITokenClaims{OrganizationID: "org-A", FederatedGraphID: "fg-A"}, + Events: []*cacheeventsv1.CacheEvent{{EventType: cacheeventsv1.EventType_L1_READ}, {EventType: cacheeventsv1.EventType_L2_READ}}, + }, + { + Claims: &utils.GraphAPITokenClaims{OrganizationID: "org-B", FederatedGraphID: "fg-B"}, + Events: []*cacheeventsv1.CacheEvent{{EventType: cacheeventsv1.EventType_L1_WRITE}}, + }, + }) + + require.Equal(t, 1, conn.batchCount(), "one ClickHouse batch per ProcessBatch call regardless of item count") + rows := conn.lastBatch().rows + require.Len(t, rows, 3) + // First two rows carry org-A claims; third row carries org-B. + require.Equal(t, "org-A", rows[0][1]) + require.Equal(t, "fg-A", rows[0][2]) + require.Equal(t, "org-A", rows[1][1]) + require.Equal(t, "org-B", rows[2][1]) + require.Equal(t, "fg-B", rows[2][2]) + require.Equal(t, 1, conn.lastBatch().sendCalls) +} diff --git a/graphqlmetrics/cmd/main.go b/graphqlmetrics/cmd/main.go index dfa087ce57..087e4f0b40 100644 --- a/graphqlmetrics/cmd/main.go +++ b/graphqlmetrics/cmd/main.go @@ -12,6 +12,7 @@ import ( "github.com/ClickHouse/clickhouse-go/v2" "github.com/amacneil/dbmate/v2/pkg/dbmate" _ "github.com/amacneil/dbmate/v2/pkg/driver/clickhouse" + "github.com/wundergraph/cosmo/graphqlmetrics/cacheevents" "github.com/wundergraph/cosmo/graphqlmetrics/config" "github.com/wundergraph/cosmo/graphqlmetrics/core" "github.com/wundergraph/cosmo/graphqlmetrics/internal/logging" @@ -120,6 +121,8 @@ func main() { ms := core.NewMetricsService(logger, conn, procConfig) + cacheEventsSvc := cacheevents.NewService(logger, conn, cacheevents.DefaultProcessorConfig()) + metricsConfig := telemetry.NewTelemetryConfig( core.Version, telemetry.PrometheusConfig{ @@ -136,6 +139,7 @@ func main() { core.WithListenAddr(cfg.ListenAddr), core.WithLogger(logger), core.WithMetrics(metricsConfig), + core.WithCacheEventsService(cacheEventsSvc), ) go func() { @@ -159,6 +163,12 @@ func main() { ms.Shutdown(cfg.ShutdownDelay) }() + wg.Add(1) + go func() { + defer wg.Done() + cacheEventsSvc.Shutdown(cfg.ShutdownDelay) + }() + // enforce a maximum shutdown delay ctx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownDelay) defer cancel() diff --git a/graphqlmetrics/core/metrics_service.go b/graphqlmetrics/core/metrics_service.go index e7c97ae81b..8239b9cdf0 100644 --- a/graphqlmetrics/core/metrics_service.go +++ b/graphqlmetrics/core/metrics_service.go @@ -96,7 +96,7 @@ func (s *MetricsService) PublishGraphQLMetrics( claims, err := utils.GetClaims(ctx) if err != nil { - return nil, errNotAuthenticated + return nil, connect.NewError(connect.CodeUnauthenticated, errNotAuthenticated) } if len(req.Msg.SchemaUsage) == 0 { @@ -117,7 +117,7 @@ func (s *MetricsService) PublishAggregatedGraphQLMetrics(ctx context.Context, re claims, err := utils.GetClaims(ctx) if err != nil { - return nil, errNotAuthenticated + return nil, connect.NewError(connect.CodeUnauthenticated, errNotAuthenticated) } if len(req.Msg.Aggregation) == 0 { diff --git a/graphqlmetrics/core/server.go b/graphqlmetrics/core/server.go index 8fb296f05c..dfae681fc1 100644 --- a/graphqlmetrics/core/server.go +++ b/graphqlmetrics/core/server.go @@ -8,6 +8,7 @@ import ( "connectrpc.com/connect" "github.com/prometheus/client_golang/prometheus" + "github.com/wundergraph/cosmo/graphqlmetrics/gen/proto/wg/cosmo/cacheevents/v1/cacheeventsv1connect" "github.com/wundergraph/cosmo/graphqlmetrics/gen/proto/wg/cosmo/graphqlmetrics/v1/graphqlmetricsv1connect" "github.com/wundergraph/cosmo/graphqlmetrics/pkg/telemetry" "go.opentelemetry.io/otel/attribute" @@ -20,11 +21,12 @@ import ( type Option func(s *Server) type Server struct { - server *http.Server - listenAddr string - logger *zap.Logger - jwtSecret []byte - metricsService graphqlmetricsv1connect.GraphQLMetricsServiceHandler + server *http.Server + listenAddr string + logger *zap.Logger + jwtSecret []byte + metricsService graphqlmetricsv1connect.GraphQLMetricsServiceHandler + cacheEventsService cacheeventsv1connect.CacheEventsServiceHandler metricConfig *telemetry.Config prometheusServer *http.Server @@ -93,6 +95,15 @@ func (s *Server) bootstrap(ctx context.Context) { mux.Handle("/health", healthHandler) mux.Handle(path, authenticate(s.jwtSecret, s.logger, handler)) + if s.cacheEventsService != nil { + cachePath, cacheHandler := cacheeventsv1connect.NewCacheEventsServiceHandler( + s.cacheEventsService, + brotli.WithCompression(), + connect.WithInterceptors(interceptors...), + ) + mux.Handle(cachePath, authenticate(s.jwtSecret, s.logger, cacheHandler)) + } + s.server = &http.Server{ Addr: s.listenAddr, // https://ieftimov.com/posts/make-resilient-golang-net-http-servers-using-timeouts-deadlines-context-cancellation/ @@ -197,3 +208,9 @@ func WithMetrics(cfg *telemetry.Config) Option { s.metricConfig = cfg } } + +func WithCacheEventsService(handler cacheeventsv1connect.CacheEventsServiceHandler) Option { + return func(s *Server) { + s.cacheEventsService = handler + } +} diff --git a/graphqlmetrics/gen/proto/wg/cosmo/cacheevents/v1/cacheevents.pb.go b/graphqlmetrics/gen/proto/wg/cosmo/cacheevents/v1/cacheevents.pb.go new file mode 100644 index 0000000000..7fec3af36e --- /dev/null +++ b/graphqlmetrics/gen/proto/wg/cosmo/cacheevents/v1/cacheevents.pb.go @@ -0,0 +1,962 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc (unknown) +// source: wg/cosmo/cacheevents/v1/cacheevents.proto + +package cacheeventsv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type EventType int32 + +const ( + EventType_EVENT_TYPE_UNSPECIFIED EventType = 0 + EventType_L1_READ EventType = 1 + EventType_L2_READ EventType = 2 + EventType_L1_WRITE EventType = 3 + EventType_L2_WRITE EventType = 4 + EventType_FETCH_TIMING EventType = 5 + EventType_SUBGRAPH_ERROR EventType = 6 + EventType_SHADOW_COMPARISON EventType = 7 + EventType_MUTATION EventType = 8 + EventType_HEADER_IMPACT EventType = 9 + EventType_CACHE_OP_ERROR EventType = 10 + EventType_FIELD_HASH EventType = 11 + EventType_ENTITY_TYPE_INFO EventType = 12 +) + +// Enum value maps for EventType. +var ( + EventType_name = map[int32]string{ + 0: "EVENT_TYPE_UNSPECIFIED", + 1: "L1_READ", + 2: "L2_READ", + 3: "L1_WRITE", + 4: "L2_WRITE", + 5: "FETCH_TIMING", + 6: "SUBGRAPH_ERROR", + 7: "SHADOW_COMPARISON", + 8: "MUTATION", + 9: "HEADER_IMPACT", + 10: "CACHE_OP_ERROR", + 11: "FIELD_HASH", + 12: "ENTITY_TYPE_INFO", + } + EventType_value = map[string]int32{ + "EVENT_TYPE_UNSPECIFIED": 0, + "L1_READ": 1, + "L2_READ": 2, + "L1_WRITE": 3, + "L2_WRITE": 4, + "FETCH_TIMING": 5, + "SUBGRAPH_ERROR": 6, + "SHADOW_COMPARISON": 7, + "MUTATION": 8, + "HEADER_IMPACT": 9, + "CACHE_OP_ERROR": 10, + "FIELD_HASH": 11, + "ENTITY_TYPE_INFO": 12, + } +) + +func (x EventType) Enum() *EventType { + p := new(EventType) + *p = x + return p +} + +func (x EventType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (EventType) Descriptor() protoreflect.EnumDescriptor { + return file_wg_cosmo_cacheevents_v1_cacheevents_proto_enumTypes[0].Descriptor() +} + +func (EventType) Type() protoreflect.EnumType { + return &file_wg_cosmo_cacheevents_v1_cacheevents_proto_enumTypes[0] +} + +func (x EventType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use EventType.Descriptor instead. +func (EventType) EnumDescriptor() ([]byte, []int) { + return file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescGZIP(), []int{0} +} + +type CacheOpKind int32 + +const ( + CacheOpKind_CACHE_OP_KIND_UNSPECIFIED CacheOpKind = 0 + CacheOpKind_GET CacheOpKind = 1 + CacheOpKind_SET CacheOpKind = 2 + CacheOpKind_SET_NEGATIVE CacheOpKind = 3 + CacheOpKind_DELETE CacheOpKind = 4 +) + +// Enum value maps for CacheOpKind. +var ( + CacheOpKind_name = map[int32]string{ + 0: "CACHE_OP_KIND_UNSPECIFIED", + 1: "GET", + 2: "SET", + 3: "SET_NEGATIVE", + 4: "DELETE", + } + CacheOpKind_value = map[string]int32{ + "CACHE_OP_KIND_UNSPECIFIED": 0, + "GET": 1, + "SET": 2, + "SET_NEGATIVE": 3, + "DELETE": 4, + } +) + +func (x CacheOpKind) Enum() *CacheOpKind { + p := new(CacheOpKind) + *p = x + return p +} + +func (x CacheOpKind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (CacheOpKind) Descriptor() protoreflect.EnumDescriptor { + return file_wg_cosmo_cacheevents_v1_cacheevents_proto_enumTypes[1].Descriptor() +} + +func (CacheOpKind) Type() protoreflect.EnumType { + return &file_wg_cosmo_cacheevents_v1_cacheevents_proto_enumTypes[1] +} + +func (x CacheOpKind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use CacheOpKind.Descriptor instead. +func (CacheOpKind) EnumDescriptor() ([]byte, []int) { + return file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescGZIP(), []int{1} +} + +type Verdict int32 + +const ( + Verdict_VERDICT_UNSPECIFIED Verdict = 0 + Verdict_HIT Verdict = 1 + Verdict_MISS Verdict = 2 + Verdict_PARTIAL_HIT Verdict = 3 + Verdict_FRESH Verdict = 4 + Verdict_STALE Verdict = 5 +) + +// Enum value maps for Verdict. +var ( + Verdict_name = map[int32]string{ + 0: "VERDICT_UNSPECIFIED", + 1: "HIT", + 2: "MISS", + 3: "PARTIAL_HIT", + 4: "FRESH", + 5: "STALE", + } + Verdict_value = map[string]int32{ + "VERDICT_UNSPECIFIED": 0, + "HIT": 1, + "MISS": 2, + "PARTIAL_HIT": 3, + "FRESH": 4, + "STALE": 5, + } +) + +func (x Verdict) Enum() *Verdict { + p := new(Verdict) + *p = x + return p +} + +func (x Verdict) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Verdict) Descriptor() protoreflect.EnumDescriptor { + return file_wg_cosmo_cacheevents_v1_cacheevents_proto_enumTypes[2].Descriptor() +} + +func (Verdict) Type() protoreflect.EnumType { + return &file_wg_cosmo_cacheevents_v1_cacheevents_proto_enumTypes[2] +} + +func (x Verdict) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Verdict.Descriptor instead. +func (Verdict) EnumDescriptor() ([]byte, []int) { + return file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescGZIP(), []int{2} +} + +type FieldSource int32 + +const ( + FieldSource_FIELD_SOURCE_UNSPECIFIED FieldSource = 0 + FieldSource_SUBGRAPH FieldSource = 1 + FieldSource_L1 FieldSource = 2 + FieldSource_L2 FieldSource = 3 + FieldSource_SHADOW_CACHED FieldSource = 4 +) + +// Enum value maps for FieldSource. +var ( + FieldSource_name = map[int32]string{ + 0: "FIELD_SOURCE_UNSPECIFIED", + 1: "SUBGRAPH", + 2: "L1", + 3: "L2", + 4: "SHADOW_CACHED", + } + FieldSource_value = map[string]int32{ + "FIELD_SOURCE_UNSPECIFIED": 0, + "SUBGRAPH": 1, + "L1": 2, + "L2": 3, + "SHADOW_CACHED": 4, + } +) + +func (x FieldSource) Enum() *FieldSource { + p := new(FieldSource) + *p = x + return p +} + +func (x FieldSource) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (FieldSource) Descriptor() protoreflect.EnumDescriptor { + return file_wg_cosmo_cacheevents_v1_cacheevents_proto_enumTypes[3].Descriptor() +} + +func (FieldSource) Type() protoreflect.EnumType { + return &file_wg_cosmo_cacheevents_v1_cacheevents_proto_enumTypes[3] +} + +func (x FieldSource) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use FieldSource.Descriptor instead. +func (FieldSource) EnumDescriptor() ([]byte, []int) { + return file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescGZIP(), []int{3} +} + +type PublishEntityCacheEventsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Events []*CacheEvent `protobuf:"bytes,1,rep,name=events,proto3" json:"events,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PublishEntityCacheEventsRequest) Reset() { + *x = PublishEntityCacheEventsRequest{} + mi := &file_wg_cosmo_cacheevents_v1_cacheevents_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PublishEntityCacheEventsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PublishEntityCacheEventsRequest) ProtoMessage() {} + +func (x *PublishEntityCacheEventsRequest) ProtoReflect() protoreflect.Message { + mi := &file_wg_cosmo_cacheevents_v1_cacheevents_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PublishEntityCacheEventsRequest.ProtoReflect.Descriptor instead. +func (*PublishEntityCacheEventsRequest) Descriptor() ([]byte, []int) { + return file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescGZIP(), []int{0} +} + +func (x *PublishEntityCacheEventsRequest) GetEvents() []*CacheEvent { + if x != nil { + return x.Events + } + return nil +} + +type PublishEntityCacheEventsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PublishEntityCacheEventsResponse) Reset() { + *x = PublishEntityCacheEventsResponse{} + mi := &file_wg_cosmo_cacheevents_v1_cacheevents_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PublishEntityCacheEventsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PublishEntityCacheEventsResponse) ProtoMessage() {} + +func (x *PublishEntityCacheEventsResponse) ProtoReflect() protoreflect.Message { + mi := &file_wg_cosmo_cacheevents_v1_cacheevents_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PublishEntityCacheEventsResponse.ProtoReflect.Descriptor instead. +func (*PublishEntityCacheEventsResponse) Descriptor() ([]byte, []int) { + return file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescGZIP(), []int{1} +} + +// CacheEvent is one wire-format cache decision. Fields are sparse — only those +// relevant to the EventType are populated. PII NOTE: raw cache keys are never +// on the wire; only KeyHash (xxhash of the original key) is transmitted. +type CacheEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Common. + TimestampUnixNano uint64 `protobuf:"fixed64,1,opt,name=timestamp_unix_nano,json=timestampUnixNano,proto3" json:"timestamp_unix_nano,omitempty"` + EventType EventType `protobuf:"varint,2,opt,name=event_type,json=eventType,proto3,enum=wg.cosmo.cacheevents.v1.EventType" json:"event_type,omitempty"` + OperationHash string `protobuf:"bytes,3,opt,name=operation_hash,json=operationHash,proto3" json:"operation_hash,omitempty"` + OperationName string `protobuf:"bytes,4,opt,name=operation_name,json=operationName,proto3" json:"operation_name,omitempty"` + OperationType string `protobuf:"bytes,5,opt,name=operation_type,json=operationType,proto3" json:"operation_type,omitempty"` + RouterConfigVersion string `protobuf:"bytes,6,opt,name=router_config_version,json=routerConfigVersion,proto3" json:"router_config_version,omitempty"` + ClientName string `protobuf:"bytes,7,opt,name=client_name,json=clientName,proto3" json:"client_name,omitempty"` + ClientVersion string `protobuf:"bytes,8,opt,name=client_version,json=clientVersion,proto3" json:"client_version,omitempty"` + TraceId string `protobuf:"bytes,9,opt,name=trace_id,json=traceId,proto3" json:"trace_id,omitempty"` + IsShadow bool `protobuf:"varint,10,opt,name=is_shadow,json=isShadow,proto3" json:"is_shadow,omitempty"` + EntityType string `protobuf:"bytes,11,opt,name=entity_type,json=entityType,proto3" json:"entity_type,omitempty"` + SubgraphId string `protobuf:"bytes,12,opt,name=subgraph_id,json=subgraphId,proto3" json:"subgraph_id,omitempty"` + KeyHash uint64 `protobuf:"fixed64,13,opt,name=key_hash,json=keyHash,proto3" json:"key_hash,omitempty"` + // Read events (L1_READ, L2_READ). + Verdict Verdict `protobuf:"varint,20,opt,name=verdict,proto3,enum=wg.cosmo.cacheevents.v1.Verdict" json:"verdict,omitempty"` + ByteSize uint32 `protobuf:"varint,21,opt,name=byte_size,json=byteSize,proto3" json:"byte_size,omitempty"` + CacheAgeMs uint32 `protobuf:"varint,22,opt,name=cache_age_ms,json=cacheAgeMs,proto3" json:"cache_age_ms,omitempty"` + // Write events (L1_WRITE, L2_WRITE). + TtlMs uint32 `protobuf:"varint,30,opt,name=ttl_ms,json=ttlMs,proto3" json:"ttl_ms,omitempty"` + WriteReason string `protobuf:"bytes,31,opt,name=write_reason,json=writeReason,proto3" json:"write_reason,omitempty"` + Source string `protobuf:"bytes,32,opt,name=source,proto3" json:"source,omitempty"` + // Fetch timing (FETCH_TIMING). + FetchSource FieldSource `protobuf:"varint,40,opt,name=fetch_source,json=fetchSource,proto3,enum=wg.cosmo.cacheevents.v1.FieldSource" json:"fetch_source,omitempty"` + DurationMs float64 `protobuf:"fixed64,41,opt,name=duration_ms,json=durationMs,proto3" json:"duration_ms,omitempty"` + TtfbMs float64 `protobuf:"fixed64,42,opt,name=ttfb_ms,json=ttfbMs,proto3" json:"ttfb_ms,omitempty"` + ItemCount uint32 `protobuf:"varint,43,opt,name=item_count,json=itemCount,proto3" json:"item_count,omitempty"` + IsEntityFetch bool `protobuf:"varint,44,opt,name=is_entity_fetch,json=isEntityFetch,proto3" json:"is_entity_fetch,omitempty"` + HttpStatusCode uint32 `protobuf:"varint,45,opt,name=http_status_code,json=httpStatusCode,proto3" json:"http_status_code,omitempty"` + ResponseBytes uint32 `protobuf:"varint,46,opt,name=response_bytes,json=responseBytes,proto3" json:"response_bytes,omitempty"` + // Errors (SUBGRAPH_ERROR, CACHE_OP_ERROR). + ErrorMessage string `protobuf:"bytes,50,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` + ErrorCode string `protobuf:"bytes,51,opt,name=error_code,json=errorCode,proto3" json:"error_code,omitempty"` + CacheOp string `protobuf:"bytes,52,opt,name=cache_op,json=cacheOp,proto3" json:"cache_op,omitempty"` + CacheName string `protobuf:"bytes,53,opt,name=cache_name,json=cacheName,proto3" json:"cache_name,omitempty"` + // Shadow comparison (SHADOW_COMPARISON). + ShadowIsFresh bool `protobuf:"varint,60,opt,name=shadow_is_fresh,json=shadowIsFresh,proto3" json:"shadow_is_fresh,omitempty"` + CachedHash uint64 `protobuf:"fixed64,61,opt,name=cached_hash,json=cachedHash,proto3" json:"cached_hash,omitempty"` + FreshHash uint64 `protobuf:"fixed64,62,opt,name=fresh_hash,json=freshHash,proto3" json:"fresh_hash,omitempty"` + CachedBytes uint32 `protobuf:"varint,63,opt,name=cached_bytes,json=cachedBytes,proto3" json:"cached_bytes,omitempty"` + FreshBytes uint32 `protobuf:"varint,64,opt,name=fresh_bytes,json=freshBytes,proto3" json:"fresh_bytes,omitempty"` + ConfiguredTtlMs uint32 `protobuf:"varint,65,opt,name=configured_ttl_ms,json=configuredTtlMs,proto3" json:"configured_ttl_ms,omitempty"` + // Mutation impact (MUTATION). Reuses cached_hash/fresh_hash/cached_bytes/fresh_bytes above. + MutationRootField string `protobuf:"bytes,70,opt,name=mutation_root_field,json=mutationRootField,proto3" json:"mutation_root_field,omitempty"` + HadCachedValue bool `protobuf:"varint,71,opt,name=had_cached_value,json=hadCachedValue,proto3" json:"had_cached_value,omitempty"` + IsStale bool `protobuf:"varint,72,opt,name=is_stale,json=isStale,proto3" json:"is_stale,omitempty"` + // Header impact (HEADER_IMPACT). + BaseKeyHash uint64 `protobuf:"fixed64,80,opt,name=base_key_hash,json=baseKeyHash,proto3" json:"base_key_hash,omitempty"` + HeaderHash uint64 `protobuf:"fixed64,81,opt,name=header_hash,json=headerHash,proto3" json:"header_hash,omitempty"` + ResponseHash uint64 `protobuf:"fixed64,82,opt,name=response_hash,json=responseHash,proto3" json:"response_hash,omitempty"` + // FIELD_HASH only. + FieldName string `protobuf:"bytes,90,opt,name=field_name,json=fieldName,proto3" json:"field_name,omitempty"` + FieldHash uint64 `protobuf:"fixed64,91,opt,name=field_hash,json=fieldHash,proto3" json:"field_hash,omitempty"` + FieldPath []string `protobuf:"bytes,93,rep,name=field_path,json=fieldPath,proto3" json:"field_path,omitempty"` + // ENTITY_TYPE_INFO only. + EntityCount uint32 `protobuf:"varint,100,opt,name=entity_count,json=entityCount,proto3" json:"entity_count,omitempty"` + EntityUniqueKeys uint32 `protobuf:"varint,101,opt,name=entity_unique_keys,json=entityUniqueKeys,proto3" json:"entity_unique_keys,omitempty"` + // Replaces freeform `cache_op`. When set, the writer prefers this enum + // over the string for the LowCardinality column. + CacheOpKind CacheOpKind `protobuf:"varint,110,opt,name=cache_op_kind,json=cacheOpKind,proto3,enum=wg.cosmo.cacheevents.v1.CacheOpKind" json:"cache_op_kind,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CacheEvent) Reset() { + *x = CacheEvent{} + mi := &file_wg_cosmo_cacheevents_v1_cacheevents_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CacheEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CacheEvent) ProtoMessage() {} + +func (x *CacheEvent) ProtoReflect() protoreflect.Message { + mi := &file_wg_cosmo_cacheevents_v1_cacheevents_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CacheEvent.ProtoReflect.Descriptor instead. +func (*CacheEvent) Descriptor() ([]byte, []int) { + return file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescGZIP(), []int{2} +} + +func (x *CacheEvent) GetTimestampUnixNano() uint64 { + if x != nil { + return x.TimestampUnixNano + } + return 0 +} + +func (x *CacheEvent) GetEventType() EventType { + if x != nil { + return x.EventType + } + return EventType_EVENT_TYPE_UNSPECIFIED +} + +func (x *CacheEvent) GetOperationHash() string { + if x != nil { + return x.OperationHash + } + return "" +} + +func (x *CacheEvent) GetOperationName() string { + if x != nil { + return x.OperationName + } + return "" +} + +func (x *CacheEvent) GetOperationType() string { + if x != nil { + return x.OperationType + } + return "" +} + +func (x *CacheEvent) GetRouterConfigVersion() string { + if x != nil { + return x.RouterConfigVersion + } + return "" +} + +func (x *CacheEvent) GetClientName() string { + if x != nil { + return x.ClientName + } + return "" +} + +func (x *CacheEvent) GetClientVersion() string { + if x != nil { + return x.ClientVersion + } + return "" +} + +func (x *CacheEvent) GetTraceId() string { + if x != nil { + return x.TraceId + } + return "" +} + +func (x *CacheEvent) GetIsShadow() bool { + if x != nil { + return x.IsShadow + } + return false +} + +func (x *CacheEvent) GetEntityType() string { + if x != nil { + return x.EntityType + } + return "" +} + +func (x *CacheEvent) GetSubgraphId() string { + if x != nil { + return x.SubgraphId + } + return "" +} + +func (x *CacheEvent) GetKeyHash() uint64 { + if x != nil { + return x.KeyHash + } + return 0 +} + +func (x *CacheEvent) GetVerdict() Verdict { + if x != nil { + return x.Verdict + } + return Verdict_VERDICT_UNSPECIFIED +} + +func (x *CacheEvent) GetByteSize() uint32 { + if x != nil { + return x.ByteSize + } + return 0 +} + +func (x *CacheEvent) GetCacheAgeMs() uint32 { + if x != nil { + return x.CacheAgeMs + } + return 0 +} + +func (x *CacheEvent) GetTtlMs() uint32 { + if x != nil { + return x.TtlMs + } + return 0 +} + +func (x *CacheEvent) GetWriteReason() string { + if x != nil { + return x.WriteReason + } + return "" +} + +func (x *CacheEvent) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *CacheEvent) GetFetchSource() FieldSource { + if x != nil { + return x.FetchSource + } + return FieldSource_FIELD_SOURCE_UNSPECIFIED +} + +func (x *CacheEvent) GetDurationMs() float64 { + if x != nil { + return x.DurationMs + } + return 0 +} + +func (x *CacheEvent) GetTtfbMs() float64 { + if x != nil { + return x.TtfbMs + } + return 0 +} + +func (x *CacheEvent) GetItemCount() uint32 { + if x != nil { + return x.ItemCount + } + return 0 +} + +func (x *CacheEvent) GetIsEntityFetch() bool { + if x != nil { + return x.IsEntityFetch + } + return false +} + +func (x *CacheEvent) GetHttpStatusCode() uint32 { + if x != nil { + return x.HttpStatusCode + } + return 0 +} + +func (x *CacheEvent) GetResponseBytes() uint32 { + if x != nil { + return x.ResponseBytes + } + return 0 +} + +func (x *CacheEvent) GetErrorMessage() string { + if x != nil { + return x.ErrorMessage + } + return "" +} + +func (x *CacheEvent) GetErrorCode() string { + if x != nil { + return x.ErrorCode + } + return "" +} + +func (x *CacheEvent) GetCacheOp() string { + if x != nil { + return x.CacheOp + } + return "" +} + +func (x *CacheEvent) GetCacheName() string { + if x != nil { + return x.CacheName + } + return "" +} + +func (x *CacheEvent) GetShadowIsFresh() bool { + if x != nil { + return x.ShadowIsFresh + } + return false +} + +func (x *CacheEvent) GetCachedHash() uint64 { + if x != nil { + return x.CachedHash + } + return 0 +} + +func (x *CacheEvent) GetFreshHash() uint64 { + if x != nil { + return x.FreshHash + } + return 0 +} + +func (x *CacheEvent) GetCachedBytes() uint32 { + if x != nil { + return x.CachedBytes + } + return 0 +} + +func (x *CacheEvent) GetFreshBytes() uint32 { + if x != nil { + return x.FreshBytes + } + return 0 +} + +func (x *CacheEvent) GetConfiguredTtlMs() uint32 { + if x != nil { + return x.ConfiguredTtlMs + } + return 0 +} + +func (x *CacheEvent) GetMutationRootField() string { + if x != nil { + return x.MutationRootField + } + return "" +} + +func (x *CacheEvent) GetHadCachedValue() bool { + if x != nil { + return x.HadCachedValue + } + return false +} + +func (x *CacheEvent) GetIsStale() bool { + if x != nil { + return x.IsStale + } + return false +} + +func (x *CacheEvent) GetBaseKeyHash() uint64 { + if x != nil { + return x.BaseKeyHash + } + return 0 +} + +func (x *CacheEvent) GetHeaderHash() uint64 { + if x != nil { + return x.HeaderHash + } + return 0 +} + +func (x *CacheEvent) GetResponseHash() uint64 { + if x != nil { + return x.ResponseHash + } + return 0 +} + +func (x *CacheEvent) GetFieldName() string { + if x != nil { + return x.FieldName + } + return "" +} + +func (x *CacheEvent) GetFieldHash() uint64 { + if x != nil { + return x.FieldHash + } + return 0 +} + +func (x *CacheEvent) GetFieldPath() []string { + if x != nil { + return x.FieldPath + } + return nil +} + +func (x *CacheEvent) GetEntityCount() uint32 { + if x != nil { + return x.EntityCount + } + return 0 +} + +func (x *CacheEvent) GetEntityUniqueKeys() uint32 { + if x != nil { + return x.EntityUniqueKeys + } + return 0 +} + +func (x *CacheEvent) GetCacheOpKind() CacheOpKind { + if x != nil { + return x.CacheOpKind + } + return CacheOpKind_CACHE_OP_KIND_UNSPECIFIED +} + +var File_wg_cosmo_cacheevents_v1_cacheevents_proto protoreflect.FileDescriptor + +const file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDesc = "" + + "\n" + + ")wg/cosmo/cacheevents/v1/cacheevents.proto\x12\x17wg.cosmo.cacheevents.v1\"^\n" + + "\x1fPublishEntityCacheEventsRequest\x12;\n" + + "\x06events\x18\x01 \x03(\v2#.wg.cosmo.cacheevents.v1.CacheEventR\x06events\"\"\n" + + " PublishEntityCacheEventsResponse\"\xb2\x0e\n" + + "\n" + + "CacheEvent\x12.\n" + + "\x13timestamp_unix_nano\x18\x01 \x01(\x06R\x11timestampUnixNano\x12A\n" + + "\n" + + "event_type\x18\x02 \x01(\x0e2\".wg.cosmo.cacheevents.v1.EventTypeR\teventType\x12%\n" + + "\x0eoperation_hash\x18\x03 \x01(\tR\roperationHash\x12%\n" + + "\x0eoperation_name\x18\x04 \x01(\tR\roperationName\x12%\n" + + "\x0eoperation_type\x18\x05 \x01(\tR\roperationType\x122\n" + + "\x15router_config_version\x18\x06 \x01(\tR\x13routerConfigVersion\x12\x1f\n" + + "\vclient_name\x18\a \x01(\tR\n" + + "clientName\x12%\n" + + "\x0eclient_version\x18\b \x01(\tR\rclientVersion\x12\x19\n" + + "\btrace_id\x18\t \x01(\tR\atraceId\x12\x1b\n" + + "\tis_shadow\x18\n" + + " \x01(\bR\bisShadow\x12\x1f\n" + + "\ventity_type\x18\v \x01(\tR\n" + + "entityType\x12\x1f\n" + + "\vsubgraph_id\x18\f \x01(\tR\n" + + "subgraphId\x12\x19\n" + + "\bkey_hash\x18\r \x01(\x06R\akeyHash\x12:\n" + + "\averdict\x18\x14 \x01(\x0e2 .wg.cosmo.cacheevents.v1.VerdictR\averdict\x12\x1b\n" + + "\tbyte_size\x18\x15 \x01(\rR\bbyteSize\x12 \n" + + "\fcache_age_ms\x18\x16 \x01(\rR\n" + + "cacheAgeMs\x12\x15\n" + + "\x06ttl_ms\x18\x1e \x01(\rR\x05ttlMs\x12!\n" + + "\fwrite_reason\x18\x1f \x01(\tR\vwriteReason\x12\x16\n" + + "\x06source\x18 \x01(\tR\x06source\x12G\n" + + "\ffetch_source\x18( \x01(\x0e2$.wg.cosmo.cacheevents.v1.FieldSourceR\vfetchSource\x12\x1f\n" + + "\vduration_ms\x18) \x01(\x01R\n" + + "durationMs\x12\x17\n" + + "\attfb_ms\x18* \x01(\x01R\x06ttfbMs\x12\x1d\n" + + "\n" + + "item_count\x18+ \x01(\rR\titemCount\x12&\n" + + "\x0fis_entity_fetch\x18, \x01(\bR\risEntityFetch\x12(\n" + + "\x10http_status_code\x18- \x01(\rR\x0ehttpStatusCode\x12%\n" + + "\x0eresponse_bytes\x18. \x01(\rR\rresponseBytes\x12#\n" + + "\rerror_message\x182 \x01(\tR\ferrorMessage\x12\x1d\n" + + "\n" + + "error_code\x183 \x01(\tR\terrorCode\x12\x19\n" + + "\bcache_op\x184 \x01(\tR\acacheOp\x12\x1d\n" + + "\n" + + "cache_name\x185 \x01(\tR\tcacheName\x12&\n" + + "\x0fshadow_is_fresh\x18< \x01(\bR\rshadowIsFresh\x12\x1f\n" + + "\vcached_hash\x18= \x01(\x06R\n" + + "cachedHash\x12\x1d\n" + + "\n" + + "fresh_hash\x18> \x01(\x06R\tfreshHash\x12!\n" + + "\fcached_bytes\x18? \x01(\rR\vcachedBytes\x12\x1f\n" + + "\vfresh_bytes\x18@ \x01(\rR\n" + + "freshBytes\x12*\n" + + "\x11configured_ttl_ms\x18A \x01(\rR\x0fconfiguredTtlMs\x12.\n" + + "\x13mutation_root_field\x18F \x01(\tR\x11mutationRootField\x12(\n" + + "\x10had_cached_value\x18G \x01(\bR\x0ehadCachedValue\x12\x19\n" + + "\bis_stale\x18H \x01(\bR\aisStale\x12\"\n" + + "\rbase_key_hash\x18P \x01(\x06R\vbaseKeyHash\x12\x1f\n" + + "\vheader_hash\x18Q \x01(\x06R\n" + + "headerHash\x12#\n" + + "\rresponse_hash\x18R \x01(\x06R\fresponseHash\x12\x1d\n" + + "\n" + + "field_name\x18Z \x01(\tR\tfieldName\x12\x1d\n" + + "\n" + + "field_hash\x18[ \x01(\x06R\tfieldHash\x12\x1d\n" + + "\n" + + "field_path\x18] \x03(\tR\tfieldPath\x12!\n" + + "\fentity_count\x18d \x01(\rR\ventityCount\x12,\n" + + "\x12entity_unique_keys\x18e \x01(\rR\x10entityUniqueKeys\x12H\n" + + "\rcache_op_kind\x18n \x01(\x0e2$.wg.cosmo.cacheevents.v1.CacheOpKindR\vcacheOpKindJ\x04\b\\\x10]R\x10parent_type_name*\xf5\x01\n" + + "\tEventType\x12\x1a\n" + + "\x16EVENT_TYPE_UNSPECIFIED\x10\x00\x12\v\n" + + "\aL1_READ\x10\x01\x12\v\n" + + "\aL2_READ\x10\x02\x12\f\n" + + "\bL1_WRITE\x10\x03\x12\f\n" + + "\bL2_WRITE\x10\x04\x12\x10\n" + + "\fFETCH_TIMING\x10\x05\x12\x12\n" + + "\x0eSUBGRAPH_ERROR\x10\x06\x12\x15\n" + + "\x11SHADOW_COMPARISON\x10\a\x12\f\n" + + "\bMUTATION\x10\b\x12\x11\n" + + "\rHEADER_IMPACT\x10\t\x12\x12\n" + + "\x0eCACHE_OP_ERROR\x10\n" + + "\x12\x0e\n" + + "\n" + + "FIELD_HASH\x10\v\x12\x14\n" + + "\x10ENTITY_TYPE_INFO\x10\f*\\\n" + + "\vCacheOpKind\x12\x1d\n" + + "\x19CACHE_OP_KIND_UNSPECIFIED\x10\x00\x12\a\n" + + "\x03GET\x10\x01\x12\a\n" + + "\x03SET\x10\x02\x12\x10\n" + + "\fSET_NEGATIVE\x10\x03\x12\n" + + "\n" + + "\x06DELETE\x10\x04*\\\n" + + "\aVerdict\x12\x17\n" + + "\x13VERDICT_UNSPECIFIED\x10\x00\x12\a\n" + + "\x03HIT\x10\x01\x12\b\n" + + "\x04MISS\x10\x02\x12\x0f\n" + + "\vPARTIAL_HIT\x10\x03\x12\t\n" + + "\x05FRESH\x10\x04\x12\t\n" + + "\x05STALE\x10\x05*\\\n" + + "\vFieldSource\x12\x1c\n" + + "\x18FIELD_SOURCE_UNSPECIFIED\x10\x00\x12\f\n" + + "\bSUBGRAPH\x10\x01\x12\x06\n" + + "\x02L1\x10\x02\x12\x06\n" + + "\x02L2\x10\x03\x12\x11\n" + + "\rSHADOW_CACHED\x10\x042\xa8\x01\n" + + "\x12CacheEventsService\x12\x91\x01\n" + + "\x18PublishEntityCacheEvents\x128.wg.cosmo.cacheevents.v1.PublishEntityCacheEventsRequest\x1a9.wg.cosmo.cacheevents.v1.PublishEntityCacheEventsResponse\"\x00B\x8b\x02\n" + + "\x1bcom.wg.cosmo.cacheevents.v1B\x10CacheeventsProtoP\x01Z[github.com/wundergraph/cosmo/graphqlmetrics/gen/proto/wg/cosmo/cacheevents/v1;cacheeventsv1\xa2\x02\x03WCC\xaa\x02\x17Wg.Cosmo.Cacheevents.V1\xca\x02\x17Wg\\Cosmo\\Cacheevents\\V1\xe2\x02#Wg\\Cosmo\\Cacheevents\\V1\\GPBMetadata\xea\x02\x1aWg::Cosmo::Cacheevents::V1b\x06proto3" + +var ( + file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescOnce sync.Once + file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescData []byte +) + +func file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescGZIP() []byte { + file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescOnce.Do(func() { + file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDesc), len(file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDesc))) + }) + return file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescData +} + +var file_wg_cosmo_cacheevents_v1_cacheevents_proto_enumTypes = make([]protoimpl.EnumInfo, 4) +var file_wg_cosmo_cacheevents_v1_cacheevents_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_wg_cosmo_cacheevents_v1_cacheevents_proto_goTypes = []any{ + (EventType)(0), // 0: wg.cosmo.cacheevents.v1.EventType + (CacheOpKind)(0), // 1: wg.cosmo.cacheevents.v1.CacheOpKind + (Verdict)(0), // 2: wg.cosmo.cacheevents.v1.Verdict + (FieldSource)(0), // 3: wg.cosmo.cacheevents.v1.FieldSource + (*PublishEntityCacheEventsRequest)(nil), // 4: wg.cosmo.cacheevents.v1.PublishEntityCacheEventsRequest + (*PublishEntityCacheEventsResponse)(nil), // 5: wg.cosmo.cacheevents.v1.PublishEntityCacheEventsResponse + (*CacheEvent)(nil), // 6: wg.cosmo.cacheevents.v1.CacheEvent +} +var file_wg_cosmo_cacheevents_v1_cacheevents_proto_depIdxs = []int32{ + 6, // 0: wg.cosmo.cacheevents.v1.PublishEntityCacheEventsRequest.events:type_name -> wg.cosmo.cacheevents.v1.CacheEvent + 0, // 1: wg.cosmo.cacheevents.v1.CacheEvent.event_type:type_name -> wg.cosmo.cacheevents.v1.EventType + 2, // 2: wg.cosmo.cacheevents.v1.CacheEvent.verdict:type_name -> wg.cosmo.cacheevents.v1.Verdict + 3, // 3: wg.cosmo.cacheevents.v1.CacheEvent.fetch_source:type_name -> wg.cosmo.cacheevents.v1.FieldSource + 1, // 4: wg.cosmo.cacheevents.v1.CacheEvent.cache_op_kind:type_name -> wg.cosmo.cacheevents.v1.CacheOpKind + 4, // 5: wg.cosmo.cacheevents.v1.CacheEventsService.PublishEntityCacheEvents:input_type -> wg.cosmo.cacheevents.v1.PublishEntityCacheEventsRequest + 5, // 6: wg.cosmo.cacheevents.v1.CacheEventsService.PublishEntityCacheEvents:output_type -> wg.cosmo.cacheevents.v1.PublishEntityCacheEventsResponse + 6, // [6:7] is the sub-list for method output_type + 5, // [5:6] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name +} + +func init() { file_wg_cosmo_cacheevents_v1_cacheevents_proto_init() } +func file_wg_cosmo_cacheevents_v1_cacheevents_proto_init() { + if File_wg_cosmo_cacheevents_v1_cacheevents_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDesc), len(file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDesc)), + NumEnums: 4, + NumMessages: 3, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_wg_cosmo_cacheevents_v1_cacheevents_proto_goTypes, + DependencyIndexes: file_wg_cosmo_cacheevents_v1_cacheevents_proto_depIdxs, + EnumInfos: file_wg_cosmo_cacheevents_v1_cacheevents_proto_enumTypes, + MessageInfos: file_wg_cosmo_cacheevents_v1_cacheevents_proto_msgTypes, + }.Build() + File_wg_cosmo_cacheevents_v1_cacheevents_proto = out.File + file_wg_cosmo_cacheevents_v1_cacheevents_proto_goTypes = nil + file_wg_cosmo_cacheevents_v1_cacheevents_proto_depIdxs = nil +} diff --git a/graphqlmetrics/gen/proto/wg/cosmo/cacheevents/v1/cacheeventsv1connect/cacheevents.connect.go b/graphqlmetrics/gen/proto/wg/cosmo/cacheevents/v1/cacheeventsv1connect/cacheevents.connect.go new file mode 100644 index 0000000000..92a4740871 --- /dev/null +++ b/graphqlmetrics/gen/proto/wg/cosmo/cacheevents/v1/cacheeventsv1connect/cacheevents.connect.go @@ -0,0 +1,115 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: wg/cosmo/cacheevents/v1/cacheevents.proto + +package cacheeventsv1connect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + v1 "github.com/wundergraph/cosmo/graphqlmetrics/gen/proto/wg/cosmo/cacheevents/v1" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // CacheEventsServiceName is the fully-qualified name of the CacheEventsService service. + CacheEventsServiceName = "wg.cosmo.cacheevents.v1.CacheEventsService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // CacheEventsServicePublishEntityCacheEventsProcedure is the fully-qualified name of the + // CacheEventsService's PublishEntityCacheEvents RPC. + CacheEventsServicePublishEntityCacheEventsProcedure = "/wg.cosmo.cacheevents.v1.CacheEventsService/PublishEntityCacheEvents" +) + +// These variables are the protoreflect.Descriptor objects for the RPCs defined in this package. +var ( + cacheEventsServiceServiceDescriptor = v1.File_wg_cosmo_cacheevents_v1_cacheevents_proto.Services().ByName("CacheEventsService") + cacheEventsServicePublishEntityCacheEventsMethodDescriptor = cacheEventsServiceServiceDescriptor.Methods().ByName("PublishEntityCacheEvents") +) + +// CacheEventsServiceClient is a client for the wg.cosmo.cacheevents.v1.CacheEventsService service. +type CacheEventsServiceClient interface { + PublishEntityCacheEvents(context.Context, *connect.Request[v1.PublishEntityCacheEventsRequest]) (*connect.Response[v1.PublishEntityCacheEventsResponse], error) +} + +// NewCacheEventsServiceClient constructs a client for the +// wg.cosmo.cacheevents.v1.CacheEventsService service. By default, it uses the Connect protocol with +// the binary Protobuf Codec, asks for gzipped responses, and sends uncompressed requests. To use +// the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewCacheEventsServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) CacheEventsServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + return &cacheEventsServiceClient{ + publishEntityCacheEvents: connect.NewClient[v1.PublishEntityCacheEventsRequest, v1.PublishEntityCacheEventsResponse]( + httpClient, + baseURL+CacheEventsServicePublishEntityCacheEventsProcedure, + connect.WithSchema(cacheEventsServicePublishEntityCacheEventsMethodDescriptor), + connect.WithClientOptions(opts...), + ), + } +} + +// cacheEventsServiceClient implements CacheEventsServiceClient. +type cacheEventsServiceClient struct { + publishEntityCacheEvents *connect.Client[v1.PublishEntityCacheEventsRequest, v1.PublishEntityCacheEventsResponse] +} + +// PublishEntityCacheEvents calls +// wg.cosmo.cacheevents.v1.CacheEventsService.PublishEntityCacheEvents. +func (c *cacheEventsServiceClient) PublishEntityCacheEvents(ctx context.Context, req *connect.Request[v1.PublishEntityCacheEventsRequest]) (*connect.Response[v1.PublishEntityCacheEventsResponse], error) { + return c.publishEntityCacheEvents.CallUnary(ctx, req) +} + +// CacheEventsServiceHandler is an implementation of the wg.cosmo.cacheevents.v1.CacheEventsService +// service. +type CacheEventsServiceHandler interface { + PublishEntityCacheEvents(context.Context, *connect.Request[v1.PublishEntityCacheEventsRequest]) (*connect.Response[v1.PublishEntityCacheEventsResponse], error) +} + +// NewCacheEventsServiceHandler builds an HTTP handler from the service implementation. It returns +// the path on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewCacheEventsServiceHandler(svc CacheEventsServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + cacheEventsServicePublishEntityCacheEventsHandler := connect.NewUnaryHandler( + CacheEventsServicePublishEntityCacheEventsProcedure, + svc.PublishEntityCacheEvents, + connect.WithSchema(cacheEventsServicePublishEntityCacheEventsMethodDescriptor), + connect.WithHandlerOptions(opts...), + ) + return "/wg.cosmo.cacheevents.v1.CacheEventsService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case CacheEventsServicePublishEntityCacheEventsProcedure: + cacheEventsServicePublishEntityCacheEventsHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedCacheEventsServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedCacheEventsServiceHandler struct{} + +func (UnimplementedCacheEventsServiceHandler) PublishEntityCacheEvents(context.Context, *connect.Request[v1.PublishEntityCacheEventsRequest]) (*connect.Response[v1.PublishEntityCacheEventsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("wg.cosmo.cacheevents.v1.CacheEventsService.PublishEntityCacheEvents is not implemented")) +} diff --git a/proto/wg/cosmo/cacheevents/v1/cacheevents.proto b/proto/wg/cosmo/cacheevents/v1/cacheevents.proto new file mode 100644 index 0000000000..b42008fa99 --- /dev/null +++ b/proto/wg/cosmo/cacheevents/v1/cacheevents.proto @@ -0,0 +1,151 @@ +syntax = "proto3"; + +package wg.cosmo.cacheevents.v1; + +// CacheEventsService receives raw entity-cache decision events from the router. +// Events are ingested per-fetch (one event per cache decision) and persisted +// into ClickHouse for analytics. +service CacheEventsService { + rpc PublishEntityCacheEvents(PublishEntityCacheEventsRequest) + returns (PublishEntityCacheEventsResponse) {} +} + +message PublishEntityCacheEventsRequest { + repeated CacheEvent events = 1; +} + +message PublishEntityCacheEventsResponse {} + +enum EventType { + EVENT_TYPE_UNSPECIFIED = 0; + L1_READ = 1; + L2_READ = 2; + L1_WRITE = 3; + L2_WRITE = 4; + FETCH_TIMING = 5; + SUBGRAPH_ERROR = 6; + SHADOW_COMPARISON = 7; + MUTATION = 8; + HEADER_IMPACT = 9; + CACHE_OP_ERROR = 10; + FIELD_HASH = 11; + ENTITY_TYPE_INFO = 12; +} + +enum CacheOpKind { + CACHE_OP_KIND_UNSPECIFIED = 0; + GET = 1; + SET = 2; + SET_NEGATIVE = 3; + DELETE = 4; +} + +enum Verdict { + VERDICT_UNSPECIFIED = 0; + HIT = 1; + MISS = 2; + PARTIAL_HIT = 3; + FRESH = 4; + STALE = 5; +} + +enum FieldSource { + FIELD_SOURCE_UNSPECIFIED = 0; + SUBGRAPH = 1; + L1 = 2; + L2 = 3; + SHADOW_CACHED = 4; +} + +// CacheEvent is one wire-format cache decision. Fields are sparse — only those +// relevant to the EventType are populated. PII NOTE: raw cache keys are never +// on the wire; only KeyHash (xxhash of the original key) is transmitted. +message CacheEvent { + // Common. + fixed64 timestamp_unix_nano = 1; + EventType event_type = 2; + + string operation_hash = 3; + string operation_name = 4; + string operation_type = 5; + string router_config_version = 6; + string client_name = 7; + string client_version = 8; + string trace_id = 9; + bool is_shadow = 10; + + string entity_type = 11; + string subgraph_id = 12; + fixed64 key_hash = 13; + + // Read events (L1_READ, L2_READ). + Verdict verdict = 20; + uint32 byte_size = 21; + uint32 cache_age_ms = 22; + + // Write events (L1_WRITE, L2_WRITE). + uint32 ttl_ms = 30; + string write_reason = 31; + string source = 32; + + // Fetch timing (FETCH_TIMING). + FieldSource fetch_source = 40; + double duration_ms = 41; + double ttfb_ms = 42; + uint32 item_count = 43; + bool is_entity_fetch = 44; + uint32 http_status_code = 45; + uint32 response_bytes = 46; + + // Errors (SUBGRAPH_ERROR, CACHE_OP_ERROR). + string error_message = 50; + string error_code = 51; + string cache_op = 52; + string cache_name = 53; + + // Shadow comparison (SHADOW_COMPARISON). + bool shadow_is_fresh = 60; + fixed64 cached_hash = 61; + fixed64 fresh_hash = 62; + uint32 cached_bytes = 63; + uint32 fresh_bytes = 64; + uint32 configured_ttl_ms = 65; + + // Mutation impact (MUTATION). Reuses cached_hash/fresh_hash/cached_bytes/fresh_bytes above. + string mutation_root_field = 70; + bool had_cached_value = 71; + bool is_stale = 72; + + // Header impact (HEADER_IMPACT). + fixed64 base_key_hash = 80; + fixed64 header_hash = 81; + fixed64 response_hash = 82; + + // FIELD_HASH only. + string field_name = 90; + fixed64 field_hash = 91; + // field_path disambiguates value-type leaves at arbitrary nesting depth. + // It is the chain of schema field names from the enclosing entity down to + // (but not including) the hashed leaf. Empty for direct entity scalars. + // Schema-aware consumers can resolve the parent type by walking field_path + // against the schema. Examples: + // User.email → field_path=[] + // User.address.street → field_path=["address"] + // User.profile.location.street → field_path=["profile","location"] + // User.tags[*].label → field_path=["tags"] (no array indexes) + // Crossing into a nested entity resets the path — the inner entity becomes + // its own scope. + // Field number 92 is reserved (was parent_type_name, dropped pre-release as + // redundant given field_path + schema lookup). + reserved 92; + reserved "parent_type_name"; + repeated string field_path = 93; + + // ENTITY_TYPE_INFO only. + uint32 entity_count = 100; + uint32 entity_unique_keys = 101; + + // Replaces freeform `cache_op`. When set, the writer prefers this enum + // over the string for the LowCardinality column. + CacheOpKind cache_op_kind = 110; +} diff --git a/router-tests/entity_caching/cache_events_export_test.go b/router-tests/entity_caching/cache_events_export_test.go new file mode 100644 index 0000000000..f3b1c56597 --- /dev/null +++ b/router-tests/entity_caching/cache_events_export_test.go @@ -0,0 +1,119 @@ +package entity_caching + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/stretchr/testify/require" + "github.com/wundergraph/cosmo/router-tests/testenv" + "github.com/wundergraph/cosmo/router/core" + cacheeventsv1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/cacheevents/v1" + "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/cacheevents/v1/cacheeventsv1connect" + "github.com/wundergraph/cosmo/router/pkg/config" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" +) + +type capturingCacheEventsHandler struct { + cacheeventsv1connect.UnimplementedCacheEventsServiceHandler + + mu sync.Mutex + auth []string + events []*cacheeventsv1.CacheEvent +} + +func (h *capturingCacheEventsHandler) PublishEntityCacheEvents( + _ context.Context, + req *connect.Request[cacheeventsv1.PublishEntityCacheEventsRequest], +) (*connect.Response[cacheeventsv1.PublishEntityCacheEventsResponse], error) { + h.mu.Lock() + h.auth = append(h.auth, req.Header().Get("Authorization")) + h.events = append(h.events, req.Msg.GetEvents()...) + h.mu.Unlock() + return connect.NewResponse(&cacheeventsv1.PublishEntityCacheEventsResponse{}), nil +} + +func (h *capturingCacheEventsHandler) snapshot() (auths []string, events []*cacheeventsv1.CacheEvent) { + h.mu.Lock() + defer h.mu.Unlock() + auths = append([]string(nil), h.auth...) + events = append([]*cacheeventsv1.CacheEvent(nil), h.events...) + return auths, events +} + +// TestCacheEventsExport_DeliversBatchesWithBearerAuth is the end-to-end +// "before/after" guarantee for the auth refactor: with EventsExport enabled, +// the router must (a) actually deliver cache events to the configured +// endpoint, and (b) carry an `Authorization: Bearer ` header on every +// request. Auth is now contributed by exporter.WithBearerAuth at the Connect +// client layer, replacing the per-call header injection that previously lived +// in the cacheevents.Sink. +func TestCacheEventsExport_DeliversBatchesWithBearerAuth(t *testing.T) { + t.Parallel() + + handler := &capturingCacheEventsHandler{} + mux := http.NewServeMux() + path, h := cacheeventsv1connect.NewCacheEventsServiceHandler(handler) + mux.Handle(path, h) + fakeServer := httptest.NewServer(mux) + t.Cleanup(fakeServer.Close) + + servers, _ := startSubgraphServers(t) + configJSON := buildConfigJSON(servers) + cache := newMemoryCache(t) + + testenv.Run(t, &testenv.Config{ + RouterConfigJSONTemplate: configJSON, + RouterOptions: []core.Option{ + core.WithEntityCaching(config.EntityCachingConfiguration{ + Enabled: true, + L1: config.EntityCachingL1Configuration{ + Enabled: true, + }, + L2: config.EntityCachingL2Configuration{ + Enabled: false, + }, + EventsExport: config.EntityCacheEventsExportConfig{ + Enabled: true, + Endpoint: fakeServer.URL, + BatchSize: 1, + QueueSize: 64, + Interval: 100 * time.Millisecond, + }, + }), + core.WithEntityCacheInstances(map[string]resolve.LoaderCache{ + "default": cache, + }), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + // First request populates the cache; second hits the cache. Both + // emit cache events that the exporter ships to the fake endpoint. + req := testenv.GraphQLRequest{ + Query: `{ item(id: "1") { id name description } }`, + } + xEnv.MakeGraphQLRequestOK(req) + xEnv.MakeGraphQLRequestOK(req) + + // Wait for the periodic flush to deliver events from both requests. + const expectedMinEvents = 2 + require.Eventually(t, func() bool { + _, events := handler.snapshot() + return len(events) >= expectedMinEvents + }, 10*time.Second, 50*time.Millisecond, "expected cache events to be exported") + }) + + auths, events := handler.snapshot() + require.NotEmpty(t, events, "expected at least one cache event") + require.NotEmpty(t, auths, "expected at least one request to the cache events endpoint") + for i, got := range auths { + require.Truef(t, strings.HasPrefix(got, "Bearer "), + "request[%d]: expected Bearer auth header, got %q", i, got) + require.Greaterf(t, len(got), len("Bearer "), + "request[%d]: expected non-empty token in Authorization header", i) + } +} diff --git a/router-tests/observability/graphql_metrics_test.go b/router-tests/observability/graphql_metrics_test.go index ffb56075c2..8498f75629 100644 --- a/router-tests/observability/graphql_metrics_test.go +++ b/router-tests/observability/graphql_metrics_test.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -23,12 +24,15 @@ func TestGraphQLMetrics(t *testing.T) { waitForMetrics := make(chan struct{}) var ( - data []byte - request graphqlmetrics.PublishAggregatedGraphQLRequestMetricsRequest + data []byte + authHeader string + request graphqlmetrics.PublishAggregatedGraphQLRequestMetricsRequest ) fakeGraphQLMetricsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader = r.Header.Get("Authorization") + read, err := gzip.NewReader(r.Body) require.NoError(t, err) defer read.Close() @@ -72,6 +76,12 @@ func TestGraphQLMetrics(t *testing.T) { case <-time.After(60 * time.Second): t.Fatal("timeout waiting for metrics") } + // Auth is now applied by exporter.WithBearerAuth at the Connect-client + // layer (previously the sink set this manually). Assert the header still + // reaches the wire after the refactor. + require.True(t, strings.HasPrefix(authHeader, "Bearer "), "expected Bearer auth header, got %q", authHeader) + require.Greater(t, len(authHeader), len("Bearer "), "expected non-empty token in Authorization header") + err := proto.Unmarshal(data, &request) require.NoError(t, err) require.Len(t, request.Aggregation, 1) diff --git a/router/core/executor.go b/router/core/executor.go index dffcf7a885..3a35312bf4 100644 --- a/router/core/executor.go +++ b/router/core/executor.go @@ -249,6 +249,10 @@ func (b *ExecutorConfigurationBuilder) buildPlannerConfiguration(ctx context.Con planConfig.ValidateRequiredExternalFields = routerEngineCfg.Execution.ValidateRequiredExternalFields planConfig.RelaxSubgraphOperationFieldSelectionMergingNullability = routerEngineCfg.Execution.RelaxSubgraphOperationFieldSelectionMergingNullability + if entityCachingConfig != nil && entityCachingConfig.EventsExport.Enabled { + planConfig.ForceHashAnalyticsKeys = true + } + // Enable cost computation when cost control is enabled if routerEngineCfg.CostControl != nil && routerEngineCfg.CostControl.Enabled { planConfig.ComputeCosts = true diff --git a/router/core/factoryresolver.go b/router/core/factoryresolver.go index 21eee65d84..0311183ee8 100644 --- a/router/core/factoryresolver.go +++ b/router/core/factoryresolver.go @@ -316,6 +316,13 @@ func (l *Loader) Load(engineConfig *nodev1.EngineConfiguration, subgraphs []*nod var outConfig plan.Configuration // attach field usage information to the plan outConfig.DefaultFlushIntervalMillis = engineConfig.DefaultFlushInterval + + // Raw cache events depend on EntityFieldHash.KeyHash being populated so + // FIELD_HASH events can be emitted without leaking KeyRaw on the wire. + // The proto enforces PII redaction by omission (no field for raw keys), + // and builder.go drops FIELD_HASH events that lack KeyHash. The planner + // override that forces hashing on every entity lives on a newer engine + // version — until that lands, we rely on per-entity SDL configuration. for _, configuration := range engineConfig.FieldConfigurations { var args []plan.ArgumentConfiguration for _, argumentConfiguration := range configuration.ArgumentsConfiguration { diff --git a/router/core/graph_server.go b/router/core/graph_server.go index 8f34e24993..568646a8e4 100644 --- a/router/core/graph_server.go +++ b/router/core/graph_server.go @@ -1782,10 +1782,12 @@ func (s *graphServer) buildGraphMux( if s.entityCachingConfig.Enabled { handlerOpts.EntityCaching = EntityCachingHandlerOptions{ - L1Enabled: s.entityCachingConfig.L1.Enabled, - L2Enabled: s.entityCachingConfig.L2.Enabled, - GlobalKeyPrefix: s.entityCachingConfig.GlobalCacheKeyPrefix, - KeyInterceptors: s.entityCacheKeyInterceptors, + L1Enabled: s.entityCachingConfig.L1.Enabled, + L2Enabled: s.entityCachingConfig.L2.Enabled, + GlobalKeyPrefix: s.entityCachingConfig.GlobalCacheKeyPrefix, + KeyInterceptors: s.entityCacheKeyInterceptors, + EventsExporter: s.cacheEventsExporter, + RouterConfigVersion: opts.RouterConfigVersion, } for _, m := range []*rmetric.EntityCacheMetrics{s.metrics.OTLPEntityCache, s.metrics.PromEntityCache} { @@ -1793,7 +1795,6 @@ func (s *graphServer) buildGraphMux( handlerOpts.EntityCaching.Metrics = append(handlerOpts.EntityCaching.Metrics, m) } } - // TODO: Add entity analytics exporter to handler options here once analytics pipeline is implemented (see ENTITY_CACHE_ANALYTICS.md). } graphqlHandler := NewGraphQLHandler(handlerOpts) diff --git a/router/core/graphql_handler.go b/router/core/graphql_handler.go index f61fd3d463..e16c540f48 100644 --- a/router/core/graphql_handler.go +++ b/router/core/graphql_handler.go @@ -14,6 +14,7 @@ import ( "go.opentelemetry.io/otel/trace" "go.uber.org/zap" + "github.com/wundergraph/cosmo/router/internal/cacheevents" rErrors "github.com/wundergraph/cosmo/router/internal/errors" "github.com/wundergraph/cosmo/router/pkg/config" rmetric "github.com/wundergraph/cosmo/router/pkg/metric" @@ -91,11 +92,13 @@ type HandlerOptions struct { // EntityCachingHandlerOptions groups all entity caching configuration passed to the GraphQL handler. type EntityCachingHandlerOptions struct { - L1Enabled bool - L2Enabled bool - GlobalKeyPrefix string - KeyInterceptors []EntityCacheKeyInterceptor - Metrics []*rmetric.EntityCacheMetrics + L1Enabled bool + L2Enabled bool + GlobalKeyPrefix string + KeyInterceptors []EntityCacheKeyInterceptor + Metrics []*rmetric.EntityCacheMetrics + EventsExporter *cacheevents.Exporter + RouterConfigVersion string } func NewGraphQLHandler(opts HandlerOptions) *GraphQLHandler { @@ -258,7 +261,7 @@ func (h *GraphQLHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } - info, err := h.executor.Resolver.ResolveGraphQLResponse(resolveCtx, p.Response, nil, hpw) + info, err := h.executor.Resolver.ArenaResolveGraphQLResponse(resolveCtx, p.Response, hpw) reqCtx.dataSourceNames = getSubgraphNames(p.Response.DataSources) if err != nil { trackFinalResponseError(resolveCtx.Context(), err) @@ -277,7 +280,7 @@ func (h *GraphQLHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { graphqlExecutionSpan.SetAttributes(rotel.WgAcquireResolverWaitTimeMs.Int64(info.ResolveAcquireWaitTime.Milliseconds())) graphqlExecutionSpan.SetAttributes(rotel.WgResolverDeduplicatedRequest.Bool(info.ResolveDeduplicated)) - h.recordEntityCacheMetrics(resolveCtx) + h.recordEntityCacheMetrics(resolveCtx, reqCtx) case *plan.SubscriptionResponsePlan: var ( writer resolve.SubscriptionResponseWriter @@ -562,10 +565,11 @@ func (h *GraphQLHandler) setDebugCacheHeaders(w http.ResponseWriter, opCtx *oper } } -// recordEntityCacheMetrics records OTEL metrics from the cache analytics snapshot. -// TODO: Add entity analytics export here once the analytics pipeline is implemented (see ENTITY_CACHE_ANALYTICS.md). -func (h *GraphQLHandler) recordEntityCacheMetrics(resolveCtx *resolve.Context) { - if len(h.entityCaching.Metrics) == 0 { +// recordEntityCacheMetrics records OTEL metrics from the cache analytics +// snapshot and, when an exporter is configured, ships raw per-fetch events +// to the cosmo cache-events backend. +func (h *GraphQLHandler) recordEntityCacheMetrics(resolveCtx *resolve.Context, reqCtx *requestContext) { + if len(h.entityCaching.Metrics) == 0 && h.entityCaching.EventsExporter == nil { return } snapshot := resolveCtx.GetCacheStats() @@ -573,6 +577,40 @@ func (h *GraphQLHandler) recordEntityCacheMetrics(resolveCtx *resolve.Context) { for _, m := range h.entityCaching.Metrics { m.RecordSnapshot(ctx, snapshot) } + if h.entityCaching.EventsExporter != nil && reqCtx != nil { + meta := h.buildCacheEventOperationMeta(ctx, reqCtx) + events := cacheevents.BuildEvents(&snapshot, meta) + for _, ev := range events { + h.entityCaching.EventsExporter.Record(ev, false) + } + } +} + +// buildCacheEventOperationMeta extracts the operation/client/schema dimensions +// from the request context that every cache event row needs. The trace ID is +// pulled best-effort from the active span context. +func (h *GraphQLHandler) buildCacheEventOperationMeta(ctx context.Context, reqCtx *requestContext) cacheevents.OperationMeta { + meta := cacheevents.OperationMeta{ + RouterConfigVersion: h.entityCaching.RouterConfigVersion, + } + if reqCtx == nil { + return meta + } + if reqCtx.operation != nil { + meta.OperationHash = reqCtx.operation.HashString() + meta.OperationName = reqCtx.operation.name + meta.OperationType = reqCtx.operation.opType + if reqCtx.operation.clientInfo != nil { + meta.ClientName = reqCtx.operation.clientInfo.Name + meta.ClientVersion = reqCtx.operation.clientInfo.Version + } + } + if span := trace.SpanFromContext(ctx); span != nil { + if sc := span.SpanContext(); sc.IsValid() { + meta.TraceID = sc.TraceID().String() + } + } + return meta } const ( @@ -613,7 +651,7 @@ func (h *GraphQLHandler) cachingOptions(reqCtx *requestContext) resolve.CachingO return resolve.CachingOptions{ EnableL1Cache: enableL1, EnableL2Cache: enableL2, - EnableCacheAnalytics: len(h.entityCaching.Metrics) > 0, + EnableCacheAnalytics: len(h.entityCaching.Metrics) > 0 || h.entityCaching.EventsExporter != nil, GlobalCacheKeyPrefix: globalKeyPrefix, L2CacheKeyInterceptor: h.buildL2CacheKeyInterceptor(reqCtx), } diff --git a/router/core/router.go b/router/core/router.go index e6ea3385a7..0126107603 100644 --- a/router/core/router.go +++ b/router/core/router.go @@ -25,8 +25,10 @@ import ( sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.uber.org/zap" + "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/cacheevents/v1/cacheeventsv1connect" "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/graphqlmetrics/v1/graphqlmetricsv1connect" nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" + "github.com/wundergraph/cosmo/router/internal/cacheevents" "github.com/wundergraph/cosmo/router/internal/circuit" "github.com/wundergraph/cosmo/router/internal/debug" "github.com/wundergraph/cosmo/router/internal/docker" @@ -827,7 +829,7 @@ func (r *Router) NewServer(ctx context.Context) (Server, error) { // bootstrap initializes the Router. It is called by Start() and NewServer(). // It should only be called once for a Router instance. -func (r *Router) bootstrap(ctx context.Context) error { +func (r *Router) bootstrap(ctx context.Context) (err error) { if !r.bootstrapped.CompareAndSwap(false, true) { return fmt.Errorf("router is already bootstrapped") } @@ -941,11 +943,11 @@ func (r *Router) bootstrap(ctx context.Context) error { http.DefaultClient, r.graphqlMetricsConfig.CollectorEndpoint, connect.WithSendGzip(), + exporter.WithBearerAuth(r.graphApiToken), ) ge, err := graphqlmetrics.NewGraphQLMetricsExporter( r.logger, client, - r.graphApiToken, exporter.NewDefaultExporterSettings(), ) if err != nil { @@ -956,7 +958,59 @@ func (r *Router) bootstrap(ctx context.Context) error { r.logger.Info("GraphQL schema coverage metrics enabled") } - // TODO: Add entity analytics exporter setup here once the analytics pipeline is implemented (see ENTITY_CACHE_ANALYTICS.md). + if r.entityCachingConfig.Enabled && r.entityCachingConfig.EventsExport.Enabled { + endpoint := r.entityCachingConfig.EventsExport.Endpoint + if endpoint == "" { + endpoint = r.graphqlMetricsConfig.CollectorEndpoint + } + if endpoint == "" { + return errors.New("entity cache events export requires an endpoint (entity_caching.events_export.endpoint or graphql_metrics.collector_endpoint)") + } + ceClientOpts := []connect.ClientOption{connect.WithSendGzip()} + if r.graphApiToken != "" { + ceClientOpts = append(ceClientOpts, exporter.WithBearerAuth(r.graphApiToken)) + } + ceClient := cacheeventsv1connect.NewCacheEventsServiceClient( + http.DefaultClient, + endpoint, + ceClientOpts..., + ) + ceSink := cacheevents.NewSink(cacheevents.SinkConfig{ + Client: ceClient, + Logger: r.logger, + }) + ceSettings := exporter.NewDefaultExporterSettings() + if v := r.entityCachingConfig.EventsExport.BatchSize; v > 0 { + ceSettings.BatchSize = v + } + if v := r.entityCachingConfig.EventsExport.QueueSize; v > 0 { + ceSettings.QueueSize = v + } + if v := r.entityCachingConfig.EventsExport.Interval; v > 0 { + ceSettings.Interval = v + } + var ce *cacheevents.Exporter + ce, err = cacheevents.NewExporter(r.logger, ceSink, ceSettings) + if err != nil { + return fmt.Errorf("failed to validate cache events exporter: %w", err) + } + r.cacheEventsExporter = ce + // NewExporter starts a worker goroutine; if any subsequent bootstrap step + // returns a non-nil error via the named return below, drain the exporter + // instead of leaving it running for the process lifetime. Shutdown() in + // the top-level Shutdown path nil-checks the field so this is idempotent. + defer func() { + if err != nil && r.cacheEventsExporter != nil { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if subErr := r.cacheEventsExporter.Shutdown(shutdownCtx); subErr != nil { + r.logger.Warn("Failed to shutdown cache events exporter during bootstrap rollback", zap.Error(subErr)) + } + r.cacheEventsExporter = nil + } + }() + r.logger.Info("Entity cache events export enabled", zap.String("endpoint", endpoint)) + } // Create Prometheus metrics exporter for schema field usage // Note: This is separate from the Prometheus meter provider which handles OTEL metrics @@ -1889,6 +1943,14 @@ func (r *Router) Shutdown(ctx context.Context) error { }) } + if r.cacheEventsExporter != nil { + wg.Go(func() { + if subErr := r.cacheEventsExporter.Shutdown(ctx); subErr != nil { + err.Append(fmt.Errorf("failed to shutdown cache events exporter: %w", subErr)) + } + }) + } + if r.promMeterProvider != nil { wg.Go(func() { if subErr := r.promMeterProvider.Shutdown(ctx); subErr != nil { diff --git a/router/core/router_config.go b/router/core/router_config.go index 2648f4bddd..974ea2800b 100644 --- a/router/core/router_config.go +++ b/router/core/router_config.go @@ -6,6 +6,7 @@ import ( "time" nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" + "github.com/wundergraph/cosmo/router/internal/cacheevents" "github.com/wundergraph/cosmo/router/internal/graphqlmetrics" "github.com/wundergraph/cosmo/router/internal/persistedoperation" "github.com/wundergraph/cosmo/router/internal/persistedoperation/pqlmanifest" @@ -59,6 +60,7 @@ type Config struct { otlpMeterProvider *sdkmetric.MeterProvider promMeterProvider *sdkmetric.MeterProvider gqlMetricsExporter *graphqlmetrics.GraphQLMetricsExporter + cacheEventsExporter *cacheevents.Exporter corsOptions *cors.Config setConfigVersionHeader bool routerGracePeriod time.Duration diff --git a/router/gen/proto/wg/cosmo/cacheevents/v1/cacheevents.pb.go b/router/gen/proto/wg/cosmo/cacheevents/v1/cacheevents.pb.go new file mode 100644 index 0000000000..e93fac671d --- /dev/null +++ b/router/gen/proto/wg/cosmo/cacheevents/v1/cacheevents.pb.go @@ -0,0 +1,962 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc (unknown) +// source: wg/cosmo/cacheevents/v1/cacheevents.proto + +package cacheeventsv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type EventType int32 + +const ( + EventType_EVENT_TYPE_UNSPECIFIED EventType = 0 + EventType_L1_READ EventType = 1 + EventType_L2_READ EventType = 2 + EventType_L1_WRITE EventType = 3 + EventType_L2_WRITE EventType = 4 + EventType_FETCH_TIMING EventType = 5 + EventType_SUBGRAPH_ERROR EventType = 6 + EventType_SHADOW_COMPARISON EventType = 7 + EventType_MUTATION EventType = 8 + EventType_HEADER_IMPACT EventType = 9 + EventType_CACHE_OP_ERROR EventType = 10 + EventType_FIELD_HASH EventType = 11 + EventType_ENTITY_TYPE_INFO EventType = 12 +) + +// Enum value maps for EventType. +var ( + EventType_name = map[int32]string{ + 0: "EVENT_TYPE_UNSPECIFIED", + 1: "L1_READ", + 2: "L2_READ", + 3: "L1_WRITE", + 4: "L2_WRITE", + 5: "FETCH_TIMING", + 6: "SUBGRAPH_ERROR", + 7: "SHADOW_COMPARISON", + 8: "MUTATION", + 9: "HEADER_IMPACT", + 10: "CACHE_OP_ERROR", + 11: "FIELD_HASH", + 12: "ENTITY_TYPE_INFO", + } + EventType_value = map[string]int32{ + "EVENT_TYPE_UNSPECIFIED": 0, + "L1_READ": 1, + "L2_READ": 2, + "L1_WRITE": 3, + "L2_WRITE": 4, + "FETCH_TIMING": 5, + "SUBGRAPH_ERROR": 6, + "SHADOW_COMPARISON": 7, + "MUTATION": 8, + "HEADER_IMPACT": 9, + "CACHE_OP_ERROR": 10, + "FIELD_HASH": 11, + "ENTITY_TYPE_INFO": 12, + } +) + +func (x EventType) Enum() *EventType { + p := new(EventType) + *p = x + return p +} + +func (x EventType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (EventType) Descriptor() protoreflect.EnumDescriptor { + return file_wg_cosmo_cacheevents_v1_cacheevents_proto_enumTypes[0].Descriptor() +} + +func (EventType) Type() protoreflect.EnumType { + return &file_wg_cosmo_cacheevents_v1_cacheevents_proto_enumTypes[0] +} + +func (x EventType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use EventType.Descriptor instead. +func (EventType) EnumDescriptor() ([]byte, []int) { + return file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescGZIP(), []int{0} +} + +type CacheOpKind int32 + +const ( + CacheOpKind_CACHE_OP_KIND_UNSPECIFIED CacheOpKind = 0 + CacheOpKind_GET CacheOpKind = 1 + CacheOpKind_SET CacheOpKind = 2 + CacheOpKind_SET_NEGATIVE CacheOpKind = 3 + CacheOpKind_DELETE CacheOpKind = 4 +) + +// Enum value maps for CacheOpKind. +var ( + CacheOpKind_name = map[int32]string{ + 0: "CACHE_OP_KIND_UNSPECIFIED", + 1: "GET", + 2: "SET", + 3: "SET_NEGATIVE", + 4: "DELETE", + } + CacheOpKind_value = map[string]int32{ + "CACHE_OP_KIND_UNSPECIFIED": 0, + "GET": 1, + "SET": 2, + "SET_NEGATIVE": 3, + "DELETE": 4, + } +) + +func (x CacheOpKind) Enum() *CacheOpKind { + p := new(CacheOpKind) + *p = x + return p +} + +func (x CacheOpKind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (CacheOpKind) Descriptor() protoreflect.EnumDescriptor { + return file_wg_cosmo_cacheevents_v1_cacheevents_proto_enumTypes[1].Descriptor() +} + +func (CacheOpKind) Type() protoreflect.EnumType { + return &file_wg_cosmo_cacheevents_v1_cacheevents_proto_enumTypes[1] +} + +func (x CacheOpKind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use CacheOpKind.Descriptor instead. +func (CacheOpKind) EnumDescriptor() ([]byte, []int) { + return file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescGZIP(), []int{1} +} + +type Verdict int32 + +const ( + Verdict_VERDICT_UNSPECIFIED Verdict = 0 + Verdict_HIT Verdict = 1 + Verdict_MISS Verdict = 2 + Verdict_PARTIAL_HIT Verdict = 3 + Verdict_FRESH Verdict = 4 + Verdict_STALE Verdict = 5 +) + +// Enum value maps for Verdict. +var ( + Verdict_name = map[int32]string{ + 0: "VERDICT_UNSPECIFIED", + 1: "HIT", + 2: "MISS", + 3: "PARTIAL_HIT", + 4: "FRESH", + 5: "STALE", + } + Verdict_value = map[string]int32{ + "VERDICT_UNSPECIFIED": 0, + "HIT": 1, + "MISS": 2, + "PARTIAL_HIT": 3, + "FRESH": 4, + "STALE": 5, + } +) + +func (x Verdict) Enum() *Verdict { + p := new(Verdict) + *p = x + return p +} + +func (x Verdict) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Verdict) Descriptor() protoreflect.EnumDescriptor { + return file_wg_cosmo_cacheevents_v1_cacheevents_proto_enumTypes[2].Descriptor() +} + +func (Verdict) Type() protoreflect.EnumType { + return &file_wg_cosmo_cacheevents_v1_cacheevents_proto_enumTypes[2] +} + +func (x Verdict) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Verdict.Descriptor instead. +func (Verdict) EnumDescriptor() ([]byte, []int) { + return file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescGZIP(), []int{2} +} + +type FieldSource int32 + +const ( + FieldSource_FIELD_SOURCE_UNSPECIFIED FieldSource = 0 + FieldSource_SUBGRAPH FieldSource = 1 + FieldSource_L1 FieldSource = 2 + FieldSource_L2 FieldSource = 3 + FieldSource_SHADOW_CACHED FieldSource = 4 +) + +// Enum value maps for FieldSource. +var ( + FieldSource_name = map[int32]string{ + 0: "FIELD_SOURCE_UNSPECIFIED", + 1: "SUBGRAPH", + 2: "L1", + 3: "L2", + 4: "SHADOW_CACHED", + } + FieldSource_value = map[string]int32{ + "FIELD_SOURCE_UNSPECIFIED": 0, + "SUBGRAPH": 1, + "L1": 2, + "L2": 3, + "SHADOW_CACHED": 4, + } +) + +func (x FieldSource) Enum() *FieldSource { + p := new(FieldSource) + *p = x + return p +} + +func (x FieldSource) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (FieldSource) Descriptor() protoreflect.EnumDescriptor { + return file_wg_cosmo_cacheevents_v1_cacheevents_proto_enumTypes[3].Descriptor() +} + +func (FieldSource) Type() protoreflect.EnumType { + return &file_wg_cosmo_cacheevents_v1_cacheevents_proto_enumTypes[3] +} + +func (x FieldSource) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use FieldSource.Descriptor instead. +func (FieldSource) EnumDescriptor() ([]byte, []int) { + return file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescGZIP(), []int{3} +} + +type PublishEntityCacheEventsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Events []*CacheEvent `protobuf:"bytes,1,rep,name=events,proto3" json:"events,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PublishEntityCacheEventsRequest) Reset() { + *x = PublishEntityCacheEventsRequest{} + mi := &file_wg_cosmo_cacheevents_v1_cacheevents_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PublishEntityCacheEventsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PublishEntityCacheEventsRequest) ProtoMessage() {} + +func (x *PublishEntityCacheEventsRequest) ProtoReflect() protoreflect.Message { + mi := &file_wg_cosmo_cacheevents_v1_cacheevents_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PublishEntityCacheEventsRequest.ProtoReflect.Descriptor instead. +func (*PublishEntityCacheEventsRequest) Descriptor() ([]byte, []int) { + return file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescGZIP(), []int{0} +} + +func (x *PublishEntityCacheEventsRequest) GetEvents() []*CacheEvent { + if x != nil { + return x.Events + } + return nil +} + +type PublishEntityCacheEventsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PublishEntityCacheEventsResponse) Reset() { + *x = PublishEntityCacheEventsResponse{} + mi := &file_wg_cosmo_cacheevents_v1_cacheevents_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PublishEntityCacheEventsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PublishEntityCacheEventsResponse) ProtoMessage() {} + +func (x *PublishEntityCacheEventsResponse) ProtoReflect() protoreflect.Message { + mi := &file_wg_cosmo_cacheevents_v1_cacheevents_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PublishEntityCacheEventsResponse.ProtoReflect.Descriptor instead. +func (*PublishEntityCacheEventsResponse) Descriptor() ([]byte, []int) { + return file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescGZIP(), []int{1} +} + +// CacheEvent is one wire-format cache decision. Fields are sparse — only those +// relevant to the EventType are populated. PII NOTE: raw cache keys are never +// on the wire; only KeyHash (xxhash of the original key) is transmitted. +type CacheEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Common. + TimestampUnixNano uint64 `protobuf:"fixed64,1,opt,name=timestamp_unix_nano,json=timestampUnixNano,proto3" json:"timestamp_unix_nano,omitempty"` + EventType EventType `protobuf:"varint,2,opt,name=event_type,json=eventType,proto3,enum=wg.cosmo.cacheevents.v1.EventType" json:"event_type,omitempty"` + OperationHash string `protobuf:"bytes,3,opt,name=operation_hash,json=operationHash,proto3" json:"operation_hash,omitempty"` + OperationName string `protobuf:"bytes,4,opt,name=operation_name,json=operationName,proto3" json:"operation_name,omitempty"` + OperationType string `protobuf:"bytes,5,opt,name=operation_type,json=operationType,proto3" json:"operation_type,omitempty"` + RouterConfigVersion string `protobuf:"bytes,6,opt,name=router_config_version,json=routerConfigVersion,proto3" json:"router_config_version,omitempty"` + ClientName string `protobuf:"bytes,7,opt,name=client_name,json=clientName,proto3" json:"client_name,omitempty"` + ClientVersion string `protobuf:"bytes,8,opt,name=client_version,json=clientVersion,proto3" json:"client_version,omitempty"` + TraceId string `protobuf:"bytes,9,opt,name=trace_id,json=traceId,proto3" json:"trace_id,omitempty"` + IsShadow bool `protobuf:"varint,10,opt,name=is_shadow,json=isShadow,proto3" json:"is_shadow,omitempty"` + EntityType string `protobuf:"bytes,11,opt,name=entity_type,json=entityType,proto3" json:"entity_type,omitempty"` + SubgraphId string `protobuf:"bytes,12,opt,name=subgraph_id,json=subgraphId,proto3" json:"subgraph_id,omitempty"` + KeyHash uint64 `protobuf:"fixed64,13,opt,name=key_hash,json=keyHash,proto3" json:"key_hash,omitempty"` + // Read events (L1_READ, L2_READ). + Verdict Verdict `protobuf:"varint,20,opt,name=verdict,proto3,enum=wg.cosmo.cacheevents.v1.Verdict" json:"verdict,omitempty"` + ByteSize uint32 `protobuf:"varint,21,opt,name=byte_size,json=byteSize,proto3" json:"byte_size,omitempty"` + CacheAgeMs uint32 `protobuf:"varint,22,opt,name=cache_age_ms,json=cacheAgeMs,proto3" json:"cache_age_ms,omitempty"` + // Write events (L1_WRITE, L2_WRITE). + TtlMs uint32 `protobuf:"varint,30,opt,name=ttl_ms,json=ttlMs,proto3" json:"ttl_ms,omitempty"` + WriteReason string `protobuf:"bytes,31,opt,name=write_reason,json=writeReason,proto3" json:"write_reason,omitempty"` + Source string `protobuf:"bytes,32,opt,name=source,proto3" json:"source,omitempty"` + // Fetch timing (FETCH_TIMING). + FetchSource FieldSource `protobuf:"varint,40,opt,name=fetch_source,json=fetchSource,proto3,enum=wg.cosmo.cacheevents.v1.FieldSource" json:"fetch_source,omitempty"` + DurationMs float64 `protobuf:"fixed64,41,opt,name=duration_ms,json=durationMs,proto3" json:"duration_ms,omitempty"` + TtfbMs float64 `protobuf:"fixed64,42,opt,name=ttfb_ms,json=ttfbMs,proto3" json:"ttfb_ms,omitempty"` + ItemCount uint32 `protobuf:"varint,43,opt,name=item_count,json=itemCount,proto3" json:"item_count,omitempty"` + IsEntityFetch bool `protobuf:"varint,44,opt,name=is_entity_fetch,json=isEntityFetch,proto3" json:"is_entity_fetch,omitempty"` + HttpStatusCode uint32 `protobuf:"varint,45,opt,name=http_status_code,json=httpStatusCode,proto3" json:"http_status_code,omitempty"` + ResponseBytes uint32 `protobuf:"varint,46,opt,name=response_bytes,json=responseBytes,proto3" json:"response_bytes,omitempty"` + // Errors (SUBGRAPH_ERROR, CACHE_OP_ERROR). + ErrorMessage string `protobuf:"bytes,50,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` + ErrorCode string `protobuf:"bytes,51,opt,name=error_code,json=errorCode,proto3" json:"error_code,omitempty"` + CacheOp string `protobuf:"bytes,52,opt,name=cache_op,json=cacheOp,proto3" json:"cache_op,omitempty"` + CacheName string `protobuf:"bytes,53,opt,name=cache_name,json=cacheName,proto3" json:"cache_name,omitempty"` + // Shadow comparison (SHADOW_COMPARISON). + ShadowIsFresh bool `protobuf:"varint,60,opt,name=shadow_is_fresh,json=shadowIsFresh,proto3" json:"shadow_is_fresh,omitempty"` + CachedHash uint64 `protobuf:"fixed64,61,opt,name=cached_hash,json=cachedHash,proto3" json:"cached_hash,omitempty"` + FreshHash uint64 `protobuf:"fixed64,62,opt,name=fresh_hash,json=freshHash,proto3" json:"fresh_hash,omitempty"` + CachedBytes uint32 `protobuf:"varint,63,opt,name=cached_bytes,json=cachedBytes,proto3" json:"cached_bytes,omitempty"` + FreshBytes uint32 `protobuf:"varint,64,opt,name=fresh_bytes,json=freshBytes,proto3" json:"fresh_bytes,omitempty"` + ConfiguredTtlMs uint32 `protobuf:"varint,65,opt,name=configured_ttl_ms,json=configuredTtlMs,proto3" json:"configured_ttl_ms,omitempty"` + // Mutation impact (MUTATION). Reuses cached_hash/fresh_hash/cached_bytes/fresh_bytes above. + MutationRootField string `protobuf:"bytes,70,opt,name=mutation_root_field,json=mutationRootField,proto3" json:"mutation_root_field,omitempty"` + HadCachedValue bool `protobuf:"varint,71,opt,name=had_cached_value,json=hadCachedValue,proto3" json:"had_cached_value,omitempty"` + IsStale bool `protobuf:"varint,72,opt,name=is_stale,json=isStale,proto3" json:"is_stale,omitempty"` + // Header impact (HEADER_IMPACT). + BaseKeyHash uint64 `protobuf:"fixed64,80,opt,name=base_key_hash,json=baseKeyHash,proto3" json:"base_key_hash,omitempty"` + HeaderHash uint64 `protobuf:"fixed64,81,opt,name=header_hash,json=headerHash,proto3" json:"header_hash,omitempty"` + ResponseHash uint64 `protobuf:"fixed64,82,opt,name=response_hash,json=responseHash,proto3" json:"response_hash,omitempty"` + // FIELD_HASH only. + FieldName string `protobuf:"bytes,90,opt,name=field_name,json=fieldName,proto3" json:"field_name,omitempty"` + FieldHash uint64 `protobuf:"fixed64,91,opt,name=field_hash,json=fieldHash,proto3" json:"field_hash,omitempty"` + FieldPath []string `protobuf:"bytes,93,rep,name=field_path,json=fieldPath,proto3" json:"field_path,omitempty"` + // ENTITY_TYPE_INFO only. + EntityCount uint32 `protobuf:"varint,100,opt,name=entity_count,json=entityCount,proto3" json:"entity_count,omitempty"` + EntityUniqueKeys uint32 `protobuf:"varint,101,opt,name=entity_unique_keys,json=entityUniqueKeys,proto3" json:"entity_unique_keys,omitempty"` + // Replaces freeform `cache_op`. When set, the writer prefers this enum + // over the string for the LowCardinality column. + CacheOpKind CacheOpKind `protobuf:"varint,110,opt,name=cache_op_kind,json=cacheOpKind,proto3,enum=wg.cosmo.cacheevents.v1.CacheOpKind" json:"cache_op_kind,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CacheEvent) Reset() { + *x = CacheEvent{} + mi := &file_wg_cosmo_cacheevents_v1_cacheevents_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CacheEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CacheEvent) ProtoMessage() {} + +func (x *CacheEvent) ProtoReflect() protoreflect.Message { + mi := &file_wg_cosmo_cacheevents_v1_cacheevents_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CacheEvent.ProtoReflect.Descriptor instead. +func (*CacheEvent) Descriptor() ([]byte, []int) { + return file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescGZIP(), []int{2} +} + +func (x *CacheEvent) GetTimestampUnixNano() uint64 { + if x != nil { + return x.TimestampUnixNano + } + return 0 +} + +func (x *CacheEvent) GetEventType() EventType { + if x != nil { + return x.EventType + } + return EventType_EVENT_TYPE_UNSPECIFIED +} + +func (x *CacheEvent) GetOperationHash() string { + if x != nil { + return x.OperationHash + } + return "" +} + +func (x *CacheEvent) GetOperationName() string { + if x != nil { + return x.OperationName + } + return "" +} + +func (x *CacheEvent) GetOperationType() string { + if x != nil { + return x.OperationType + } + return "" +} + +func (x *CacheEvent) GetRouterConfigVersion() string { + if x != nil { + return x.RouterConfigVersion + } + return "" +} + +func (x *CacheEvent) GetClientName() string { + if x != nil { + return x.ClientName + } + return "" +} + +func (x *CacheEvent) GetClientVersion() string { + if x != nil { + return x.ClientVersion + } + return "" +} + +func (x *CacheEvent) GetTraceId() string { + if x != nil { + return x.TraceId + } + return "" +} + +func (x *CacheEvent) GetIsShadow() bool { + if x != nil { + return x.IsShadow + } + return false +} + +func (x *CacheEvent) GetEntityType() string { + if x != nil { + return x.EntityType + } + return "" +} + +func (x *CacheEvent) GetSubgraphId() string { + if x != nil { + return x.SubgraphId + } + return "" +} + +func (x *CacheEvent) GetKeyHash() uint64 { + if x != nil { + return x.KeyHash + } + return 0 +} + +func (x *CacheEvent) GetVerdict() Verdict { + if x != nil { + return x.Verdict + } + return Verdict_VERDICT_UNSPECIFIED +} + +func (x *CacheEvent) GetByteSize() uint32 { + if x != nil { + return x.ByteSize + } + return 0 +} + +func (x *CacheEvent) GetCacheAgeMs() uint32 { + if x != nil { + return x.CacheAgeMs + } + return 0 +} + +func (x *CacheEvent) GetTtlMs() uint32 { + if x != nil { + return x.TtlMs + } + return 0 +} + +func (x *CacheEvent) GetWriteReason() string { + if x != nil { + return x.WriteReason + } + return "" +} + +func (x *CacheEvent) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *CacheEvent) GetFetchSource() FieldSource { + if x != nil { + return x.FetchSource + } + return FieldSource_FIELD_SOURCE_UNSPECIFIED +} + +func (x *CacheEvent) GetDurationMs() float64 { + if x != nil { + return x.DurationMs + } + return 0 +} + +func (x *CacheEvent) GetTtfbMs() float64 { + if x != nil { + return x.TtfbMs + } + return 0 +} + +func (x *CacheEvent) GetItemCount() uint32 { + if x != nil { + return x.ItemCount + } + return 0 +} + +func (x *CacheEvent) GetIsEntityFetch() bool { + if x != nil { + return x.IsEntityFetch + } + return false +} + +func (x *CacheEvent) GetHttpStatusCode() uint32 { + if x != nil { + return x.HttpStatusCode + } + return 0 +} + +func (x *CacheEvent) GetResponseBytes() uint32 { + if x != nil { + return x.ResponseBytes + } + return 0 +} + +func (x *CacheEvent) GetErrorMessage() string { + if x != nil { + return x.ErrorMessage + } + return "" +} + +func (x *CacheEvent) GetErrorCode() string { + if x != nil { + return x.ErrorCode + } + return "" +} + +func (x *CacheEvent) GetCacheOp() string { + if x != nil { + return x.CacheOp + } + return "" +} + +func (x *CacheEvent) GetCacheName() string { + if x != nil { + return x.CacheName + } + return "" +} + +func (x *CacheEvent) GetShadowIsFresh() bool { + if x != nil { + return x.ShadowIsFresh + } + return false +} + +func (x *CacheEvent) GetCachedHash() uint64 { + if x != nil { + return x.CachedHash + } + return 0 +} + +func (x *CacheEvent) GetFreshHash() uint64 { + if x != nil { + return x.FreshHash + } + return 0 +} + +func (x *CacheEvent) GetCachedBytes() uint32 { + if x != nil { + return x.CachedBytes + } + return 0 +} + +func (x *CacheEvent) GetFreshBytes() uint32 { + if x != nil { + return x.FreshBytes + } + return 0 +} + +func (x *CacheEvent) GetConfiguredTtlMs() uint32 { + if x != nil { + return x.ConfiguredTtlMs + } + return 0 +} + +func (x *CacheEvent) GetMutationRootField() string { + if x != nil { + return x.MutationRootField + } + return "" +} + +func (x *CacheEvent) GetHadCachedValue() bool { + if x != nil { + return x.HadCachedValue + } + return false +} + +func (x *CacheEvent) GetIsStale() bool { + if x != nil { + return x.IsStale + } + return false +} + +func (x *CacheEvent) GetBaseKeyHash() uint64 { + if x != nil { + return x.BaseKeyHash + } + return 0 +} + +func (x *CacheEvent) GetHeaderHash() uint64 { + if x != nil { + return x.HeaderHash + } + return 0 +} + +func (x *CacheEvent) GetResponseHash() uint64 { + if x != nil { + return x.ResponseHash + } + return 0 +} + +func (x *CacheEvent) GetFieldName() string { + if x != nil { + return x.FieldName + } + return "" +} + +func (x *CacheEvent) GetFieldHash() uint64 { + if x != nil { + return x.FieldHash + } + return 0 +} + +func (x *CacheEvent) GetFieldPath() []string { + if x != nil { + return x.FieldPath + } + return nil +} + +func (x *CacheEvent) GetEntityCount() uint32 { + if x != nil { + return x.EntityCount + } + return 0 +} + +func (x *CacheEvent) GetEntityUniqueKeys() uint32 { + if x != nil { + return x.EntityUniqueKeys + } + return 0 +} + +func (x *CacheEvent) GetCacheOpKind() CacheOpKind { + if x != nil { + return x.CacheOpKind + } + return CacheOpKind_CACHE_OP_KIND_UNSPECIFIED +} + +var File_wg_cosmo_cacheevents_v1_cacheevents_proto protoreflect.FileDescriptor + +const file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDesc = "" + + "\n" + + ")wg/cosmo/cacheevents/v1/cacheevents.proto\x12\x17wg.cosmo.cacheevents.v1\"^\n" + + "\x1fPublishEntityCacheEventsRequest\x12;\n" + + "\x06events\x18\x01 \x03(\v2#.wg.cosmo.cacheevents.v1.CacheEventR\x06events\"\"\n" + + " PublishEntityCacheEventsResponse\"\xb2\x0e\n" + + "\n" + + "CacheEvent\x12.\n" + + "\x13timestamp_unix_nano\x18\x01 \x01(\x06R\x11timestampUnixNano\x12A\n" + + "\n" + + "event_type\x18\x02 \x01(\x0e2\".wg.cosmo.cacheevents.v1.EventTypeR\teventType\x12%\n" + + "\x0eoperation_hash\x18\x03 \x01(\tR\roperationHash\x12%\n" + + "\x0eoperation_name\x18\x04 \x01(\tR\roperationName\x12%\n" + + "\x0eoperation_type\x18\x05 \x01(\tR\roperationType\x122\n" + + "\x15router_config_version\x18\x06 \x01(\tR\x13routerConfigVersion\x12\x1f\n" + + "\vclient_name\x18\a \x01(\tR\n" + + "clientName\x12%\n" + + "\x0eclient_version\x18\b \x01(\tR\rclientVersion\x12\x19\n" + + "\btrace_id\x18\t \x01(\tR\atraceId\x12\x1b\n" + + "\tis_shadow\x18\n" + + " \x01(\bR\bisShadow\x12\x1f\n" + + "\ventity_type\x18\v \x01(\tR\n" + + "entityType\x12\x1f\n" + + "\vsubgraph_id\x18\f \x01(\tR\n" + + "subgraphId\x12\x19\n" + + "\bkey_hash\x18\r \x01(\x06R\akeyHash\x12:\n" + + "\averdict\x18\x14 \x01(\x0e2 .wg.cosmo.cacheevents.v1.VerdictR\averdict\x12\x1b\n" + + "\tbyte_size\x18\x15 \x01(\rR\bbyteSize\x12 \n" + + "\fcache_age_ms\x18\x16 \x01(\rR\n" + + "cacheAgeMs\x12\x15\n" + + "\x06ttl_ms\x18\x1e \x01(\rR\x05ttlMs\x12!\n" + + "\fwrite_reason\x18\x1f \x01(\tR\vwriteReason\x12\x16\n" + + "\x06source\x18 \x01(\tR\x06source\x12G\n" + + "\ffetch_source\x18( \x01(\x0e2$.wg.cosmo.cacheevents.v1.FieldSourceR\vfetchSource\x12\x1f\n" + + "\vduration_ms\x18) \x01(\x01R\n" + + "durationMs\x12\x17\n" + + "\attfb_ms\x18* \x01(\x01R\x06ttfbMs\x12\x1d\n" + + "\n" + + "item_count\x18+ \x01(\rR\titemCount\x12&\n" + + "\x0fis_entity_fetch\x18, \x01(\bR\risEntityFetch\x12(\n" + + "\x10http_status_code\x18- \x01(\rR\x0ehttpStatusCode\x12%\n" + + "\x0eresponse_bytes\x18. \x01(\rR\rresponseBytes\x12#\n" + + "\rerror_message\x182 \x01(\tR\ferrorMessage\x12\x1d\n" + + "\n" + + "error_code\x183 \x01(\tR\terrorCode\x12\x19\n" + + "\bcache_op\x184 \x01(\tR\acacheOp\x12\x1d\n" + + "\n" + + "cache_name\x185 \x01(\tR\tcacheName\x12&\n" + + "\x0fshadow_is_fresh\x18< \x01(\bR\rshadowIsFresh\x12\x1f\n" + + "\vcached_hash\x18= \x01(\x06R\n" + + "cachedHash\x12\x1d\n" + + "\n" + + "fresh_hash\x18> \x01(\x06R\tfreshHash\x12!\n" + + "\fcached_bytes\x18? \x01(\rR\vcachedBytes\x12\x1f\n" + + "\vfresh_bytes\x18@ \x01(\rR\n" + + "freshBytes\x12*\n" + + "\x11configured_ttl_ms\x18A \x01(\rR\x0fconfiguredTtlMs\x12.\n" + + "\x13mutation_root_field\x18F \x01(\tR\x11mutationRootField\x12(\n" + + "\x10had_cached_value\x18G \x01(\bR\x0ehadCachedValue\x12\x19\n" + + "\bis_stale\x18H \x01(\bR\aisStale\x12\"\n" + + "\rbase_key_hash\x18P \x01(\x06R\vbaseKeyHash\x12\x1f\n" + + "\vheader_hash\x18Q \x01(\x06R\n" + + "headerHash\x12#\n" + + "\rresponse_hash\x18R \x01(\x06R\fresponseHash\x12\x1d\n" + + "\n" + + "field_name\x18Z \x01(\tR\tfieldName\x12\x1d\n" + + "\n" + + "field_hash\x18[ \x01(\x06R\tfieldHash\x12\x1d\n" + + "\n" + + "field_path\x18] \x03(\tR\tfieldPath\x12!\n" + + "\fentity_count\x18d \x01(\rR\ventityCount\x12,\n" + + "\x12entity_unique_keys\x18e \x01(\rR\x10entityUniqueKeys\x12H\n" + + "\rcache_op_kind\x18n \x01(\x0e2$.wg.cosmo.cacheevents.v1.CacheOpKindR\vcacheOpKindJ\x04\b\\\x10]R\x10parent_type_name*\xf5\x01\n" + + "\tEventType\x12\x1a\n" + + "\x16EVENT_TYPE_UNSPECIFIED\x10\x00\x12\v\n" + + "\aL1_READ\x10\x01\x12\v\n" + + "\aL2_READ\x10\x02\x12\f\n" + + "\bL1_WRITE\x10\x03\x12\f\n" + + "\bL2_WRITE\x10\x04\x12\x10\n" + + "\fFETCH_TIMING\x10\x05\x12\x12\n" + + "\x0eSUBGRAPH_ERROR\x10\x06\x12\x15\n" + + "\x11SHADOW_COMPARISON\x10\a\x12\f\n" + + "\bMUTATION\x10\b\x12\x11\n" + + "\rHEADER_IMPACT\x10\t\x12\x12\n" + + "\x0eCACHE_OP_ERROR\x10\n" + + "\x12\x0e\n" + + "\n" + + "FIELD_HASH\x10\v\x12\x14\n" + + "\x10ENTITY_TYPE_INFO\x10\f*\\\n" + + "\vCacheOpKind\x12\x1d\n" + + "\x19CACHE_OP_KIND_UNSPECIFIED\x10\x00\x12\a\n" + + "\x03GET\x10\x01\x12\a\n" + + "\x03SET\x10\x02\x12\x10\n" + + "\fSET_NEGATIVE\x10\x03\x12\n" + + "\n" + + "\x06DELETE\x10\x04*\\\n" + + "\aVerdict\x12\x17\n" + + "\x13VERDICT_UNSPECIFIED\x10\x00\x12\a\n" + + "\x03HIT\x10\x01\x12\b\n" + + "\x04MISS\x10\x02\x12\x0f\n" + + "\vPARTIAL_HIT\x10\x03\x12\t\n" + + "\x05FRESH\x10\x04\x12\t\n" + + "\x05STALE\x10\x05*\\\n" + + "\vFieldSource\x12\x1c\n" + + "\x18FIELD_SOURCE_UNSPECIFIED\x10\x00\x12\f\n" + + "\bSUBGRAPH\x10\x01\x12\x06\n" + + "\x02L1\x10\x02\x12\x06\n" + + "\x02L2\x10\x03\x12\x11\n" + + "\rSHADOW_CACHED\x10\x042\xa8\x01\n" + + "\x12CacheEventsService\x12\x91\x01\n" + + "\x18PublishEntityCacheEvents\x128.wg.cosmo.cacheevents.v1.PublishEntityCacheEventsRequest\x1a9.wg.cosmo.cacheevents.v1.PublishEntityCacheEventsResponse\"\x00B\x83\x02\n" + + "\x1bcom.wg.cosmo.cacheevents.v1B\x10CacheeventsProtoP\x01ZSgithub.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/cacheevents/v1;cacheeventsv1\xa2\x02\x03WCC\xaa\x02\x17Wg.Cosmo.Cacheevents.V1\xca\x02\x17Wg\\Cosmo\\Cacheevents\\V1\xe2\x02#Wg\\Cosmo\\Cacheevents\\V1\\GPBMetadata\xea\x02\x1aWg::Cosmo::Cacheevents::V1b\x06proto3" + +var ( + file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescOnce sync.Once + file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescData []byte +) + +func file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescGZIP() []byte { + file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescOnce.Do(func() { + file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDesc), len(file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDesc))) + }) + return file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDescData +} + +var file_wg_cosmo_cacheevents_v1_cacheevents_proto_enumTypes = make([]protoimpl.EnumInfo, 4) +var file_wg_cosmo_cacheevents_v1_cacheevents_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_wg_cosmo_cacheevents_v1_cacheevents_proto_goTypes = []any{ + (EventType)(0), // 0: wg.cosmo.cacheevents.v1.EventType + (CacheOpKind)(0), // 1: wg.cosmo.cacheevents.v1.CacheOpKind + (Verdict)(0), // 2: wg.cosmo.cacheevents.v1.Verdict + (FieldSource)(0), // 3: wg.cosmo.cacheevents.v1.FieldSource + (*PublishEntityCacheEventsRequest)(nil), // 4: wg.cosmo.cacheevents.v1.PublishEntityCacheEventsRequest + (*PublishEntityCacheEventsResponse)(nil), // 5: wg.cosmo.cacheevents.v1.PublishEntityCacheEventsResponse + (*CacheEvent)(nil), // 6: wg.cosmo.cacheevents.v1.CacheEvent +} +var file_wg_cosmo_cacheevents_v1_cacheevents_proto_depIdxs = []int32{ + 6, // 0: wg.cosmo.cacheevents.v1.PublishEntityCacheEventsRequest.events:type_name -> wg.cosmo.cacheevents.v1.CacheEvent + 0, // 1: wg.cosmo.cacheevents.v1.CacheEvent.event_type:type_name -> wg.cosmo.cacheevents.v1.EventType + 2, // 2: wg.cosmo.cacheevents.v1.CacheEvent.verdict:type_name -> wg.cosmo.cacheevents.v1.Verdict + 3, // 3: wg.cosmo.cacheevents.v1.CacheEvent.fetch_source:type_name -> wg.cosmo.cacheevents.v1.FieldSource + 1, // 4: wg.cosmo.cacheevents.v1.CacheEvent.cache_op_kind:type_name -> wg.cosmo.cacheevents.v1.CacheOpKind + 4, // 5: wg.cosmo.cacheevents.v1.CacheEventsService.PublishEntityCacheEvents:input_type -> wg.cosmo.cacheevents.v1.PublishEntityCacheEventsRequest + 5, // 6: wg.cosmo.cacheevents.v1.CacheEventsService.PublishEntityCacheEvents:output_type -> wg.cosmo.cacheevents.v1.PublishEntityCacheEventsResponse + 6, // [6:7] is the sub-list for method output_type + 5, // [5:6] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name +} + +func init() { file_wg_cosmo_cacheevents_v1_cacheevents_proto_init() } +func file_wg_cosmo_cacheevents_v1_cacheevents_proto_init() { + if File_wg_cosmo_cacheevents_v1_cacheevents_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDesc), len(file_wg_cosmo_cacheevents_v1_cacheevents_proto_rawDesc)), + NumEnums: 4, + NumMessages: 3, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_wg_cosmo_cacheevents_v1_cacheevents_proto_goTypes, + DependencyIndexes: file_wg_cosmo_cacheevents_v1_cacheevents_proto_depIdxs, + EnumInfos: file_wg_cosmo_cacheevents_v1_cacheevents_proto_enumTypes, + MessageInfos: file_wg_cosmo_cacheevents_v1_cacheevents_proto_msgTypes, + }.Build() + File_wg_cosmo_cacheevents_v1_cacheevents_proto = out.File + file_wg_cosmo_cacheevents_v1_cacheevents_proto_goTypes = nil + file_wg_cosmo_cacheevents_v1_cacheevents_proto_depIdxs = nil +} diff --git a/router/gen/proto/wg/cosmo/cacheevents/v1/cacheeventsv1connect/cacheevents.connect.go b/router/gen/proto/wg/cosmo/cacheevents/v1/cacheeventsv1connect/cacheevents.connect.go new file mode 100644 index 0000000000..0198f6a54f --- /dev/null +++ b/router/gen/proto/wg/cosmo/cacheevents/v1/cacheeventsv1connect/cacheevents.connect.go @@ -0,0 +1,115 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: wg/cosmo/cacheevents/v1/cacheevents.proto + +package cacheeventsv1connect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + v1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/cacheevents/v1" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // CacheEventsServiceName is the fully-qualified name of the CacheEventsService service. + CacheEventsServiceName = "wg.cosmo.cacheevents.v1.CacheEventsService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // CacheEventsServicePublishEntityCacheEventsProcedure is the fully-qualified name of the + // CacheEventsService's PublishEntityCacheEvents RPC. + CacheEventsServicePublishEntityCacheEventsProcedure = "/wg.cosmo.cacheevents.v1.CacheEventsService/PublishEntityCacheEvents" +) + +// These variables are the protoreflect.Descriptor objects for the RPCs defined in this package. +var ( + cacheEventsServiceServiceDescriptor = v1.File_wg_cosmo_cacheevents_v1_cacheevents_proto.Services().ByName("CacheEventsService") + cacheEventsServicePublishEntityCacheEventsMethodDescriptor = cacheEventsServiceServiceDescriptor.Methods().ByName("PublishEntityCacheEvents") +) + +// CacheEventsServiceClient is a client for the wg.cosmo.cacheevents.v1.CacheEventsService service. +type CacheEventsServiceClient interface { + PublishEntityCacheEvents(context.Context, *connect.Request[v1.PublishEntityCacheEventsRequest]) (*connect.Response[v1.PublishEntityCacheEventsResponse], error) +} + +// NewCacheEventsServiceClient constructs a client for the +// wg.cosmo.cacheevents.v1.CacheEventsService service. By default, it uses the Connect protocol with +// the binary Protobuf Codec, asks for gzipped responses, and sends uncompressed requests. To use +// the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewCacheEventsServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) CacheEventsServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + return &cacheEventsServiceClient{ + publishEntityCacheEvents: connect.NewClient[v1.PublishEntityCacheEventsRequest, v1.PublishEntityCacheEventsResponse]( + httpClient, + baseURL+CacheEventsServicePublishEntityCacheEventsProcedure, + connect.WithSchema(cacheEventsServicePublishEntityCacheEventsMethodDescriptor), + connect.WithClientOptions(opts...), + ), + } +} + +// cacheEventsServiceClient implements CacheEventsServiceClient. +type cacheEventsServiceClient struct { + publishEntityCacheEvents *connect.Client[v1.PublishEntityCacheEventsRequest, v1.PublishEntityCacheEventsResponse] +} + +// PublishEntityCacheEvents calls +// wg.cosmo.cacheevents.v1.CacheEventsService.PublishEntityCacheEvents. +func (c *cacheEventsServiceClient) PublishEntityCacheEvents(ctx context.Context, req *connect.Request[v1.PublishEntityCacheEventsRequest]) (*connect.Response[v1.PublishEntityCacheEventsResponse], error) { + return c.publishEntityCacheEvents.CallUnary(ctx, req) +} + +// CacheEventsServiceHandler is an implementation of the wg.cosmo.cacheevents.v1.CacheEventsService +// service. +type CacheEventsServiceHandler interface { + PublishEntityCacheEvents(context.Context, *connect.Request[v1.PublishEntityCacheEventsRequest]) (*connect.Response[v1.PublishEntityCacheEventsResponse], error) +} + +// NewCacheEventsServiceHandler builds an HTTP handler from the service implementation. It returns +// the path on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewCacheEventsServiceHandler(svc CacheEventsServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + cacheEventsServicePublishEntityCacheEventsHandler := connect.NewUnaryHandler( + CacheEventsServicePublishEntityCacheEventsProcedure, + svc.PublishEntityCacheEvents, + connect.WithSchema(cacheEventsServicePublishEntityCacheEventsMethodDescriptor), + connect.WithHandlerOptions(opts...), + ) + return "/wg.cosmo.cacheevents.v1.CacheEventsService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case CacheEventsServicePublishEntityCacheEventsProcedure: + cacheEventsServicePublishEntityCacheEventsHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedCacheEventsServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedCacheEventsServiceHandler struct{} + +func (UnimplementedCacheEventsServiceHandler) PublishEntityCacheEvents(context.Context, *connect.Request[v1.PublishEntityCacheEventsRequest]) (*connect.Response[v1.PublishEntityCacheEventsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("wg.cosmo.cacheevents.v1.CacheEventsService.PublishEntityCacheEvents is not implemented")) +} diff --git a/router/internal/cacheevents/aggregate.go b/router/internal/cacheevents/aggregate.go new file mode 100644 index 0000000000..6c29f10cbe --- /dev/null +++ b/router/internal/cacheevents/aggregate.go @@ -0,0 +1,14 @@ +package cacheevents + +import ( + cacheeventsv1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/cacheevents/v1" +) + +// BuildRequest wraps a finished batch of events into the wire-format request +// the backend expects. Events are already at finest grain — there is no +// further aggregation worth doing on the router side. +func BuildRequest(batch []*cacheeventsv1.CacheEvent) *cacheeventsv1.PublishEntityCacheEventsRequest { + return &cacheeventsv1.PublishEntityCacheEventsRequest{ + Events: batch, + } +} diff --git a/router/internal/cacheevents/aggregate_test.go b/router/internal/cacheevents/aggregate_test.go new file mode 100644 index 0000000000..b5f9b781f9 --- /dev/null +++ b/router/internal/cacheevents/aggregate_test.go @@ -0,0 +1,37 @@ +package cacheevents + +import ( + "testing" + + "github.com/stretchr/testify/require" + cacheeventsv1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/cacheevents/v1" +) + +func TestBuildRequest(t *testing.T) { + t.Parallel() + + t.Run("nil batch produces a non-nil request with nil events", func(t *testing.T) { + req := BuildRequest(nil) + require.NotNil(t, req) + require.Nil(t, req.Events) + }) + + t.Run("empty batch produces an empty request", func(t *testing.T) { + req := BuildRequest([]*cacheeventsv1.CacheEvent{}) + require.NotNil(t, req) + require.Empty(t, req.Events) + }) + + t.Run("batch is referenced as-is", func(t *testing.T) { + batch := []*cacheeventsv1.CacheEvent{ + {EventType: cacheeventsv1.EventType_L1_READ, EntityType: "User"}, + {EventType: cacheeventsv1.EventType_L2_WRITE, EntityType: "Product"}, + } + req := BuildRequest(batch) + require.NotNil(t, req) + require.Len(t, req.Events, 2) + // Same backing slice — wrapping should not allocate or copy. + require.Same(t, batch[0], req.Events[0]) + require.Same(t, batch[1], req.Events[1]) + }) +} diff --git a/router/internal/cacheevents/builder.go b/router/internal/cacheevents/builder.go new file mode 100644 index 0000000000..9de2a0ca89 --- /dev/null +++ b/router/internal/cacheevents/builder.go @@ -0,0 +1,256 @@ +package cacheevents + +import ( + "strings" + "time" + + "github.com/cespare/xxhash/v2" + cacheeventsv1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/cacheevents/v1" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" +) + +// OperationMeta is the per-request context carried onto every CacheEvent. +// It mirrors the operation/client/schema dimensions used by graphqlmetrics +// SchemaUsageInfo so cache events join cleanly with schema-usage data. +type OperationMeta struct { + OperationHash string + OperationName string + OperationType string + RouterConfigVersion string + ClientName string + ClientVersion string + TraceID string +} + +// BuildEvents converts a CacheAnalyticsSnapshot into a slice of wire-format +// CacheEvent records. Raw cache keys are hashed via xxhash before they leave +// this function — the wire protocol has no field for raw keys, so PII +// containment is enforced by the proto's shape. +func BuildEvents(snapshot *resolve.CacheAnalyticsSnapshot, meta OperationMeta) []*cacheeventsv1.CacheEvent { + if snapshot == nil { + return nil + } + total := len(snapshot.L1Reads) + len(snapshot.L2Reads) + + len(snapshot.L1Writes) + len(snapshot.L2Writes) + + len(snapshot.FetchTimings) + len(snapshot.ErrorEvents) + + len(snapshot.ShadowComparisons) + len(snapshot.MutationEvents) + + len(snapshot.HeaderImpactEvents) + len(snapshot.CacheOpErrors) + + len(snapshot.FieldHashes) + len(snapshot.EntityTypes) + if total == 0 { + return nil + } + + // All events in a single snapshot share the build-time timestamp. The + // engine does not yet stamp per-event timestamps; once it does, swap to + // the per-event field. + now := uint64(time.Now().UnixNano()) + + out := make([]*cacheeventsv1.CacheEvent, 0, total) + + for i := range snapshot.L1Reads { + ev := &snapshot.L1Reads[i] + out = append(out, fillCommon(meta, now, cacheeventsv1.EventType_L1_READ, ev.EntityType, ev.DataSource, ev.Shadow, &cacheeventsv1.CacheEvent{ + KeyHash: hashKey(ev.CacheKey), + Verdict: verdictFromKind(ev.Kind), + ByteSize: uint32(ev.ByteSize), + CacheAgeMs: uint32(ev.CacheAgeMs), + })) + } + for i := range snapshot.L2Reads { + ev := &snapshot.L2Reads[i] + out = append(out, fillCommon(meta, now, cacheeventsv1.EventType_L2_READ, ev.EntityType, ev.DataSource, ev.Shadow, &cacheeventsv1.CacheEvent{ + KeyHash: hashKey(ev.CacheKey), + Verdict: verdictFromKind(ev.Kind), + ByteSize: uint32(ev.ByteSize), + CacheAgeMs: uint32(ev.CacheAgeMs), + })) + } + for i := range snapshot.L1Writes { + ev := &snapshot.L1Writes[i] + out = append(out, fillCommon(meta, now, cacheeventsv1.EventType_L1_WRITE, ev.EntityType, ev.DataSource, ev.Shadow, &cacheeventsv1.CacheEvent{ + KeyHash: hashKey(ev.CacheKey), + ByteSize: uint32(ev.ByteSize), + TtlMs: uint32(ev.TTL / time.Millisecond), + WriteReason: string(ev.WriteReason), + Source: string(ev.Source), + })) + } + for i := range snapshot.L2Writes { + ev := &snapshot.L2Writes[i] + out = append(out, fillCommon(meta, now, cacheeventsv1.EventType_L2_WRITE, ev.EntityType, ev.DataSource, ev.Shadow, &cacheeventsv1.CacheEvent{ + KeyHash: hashKey(ev.CacheKey), + ByteSize: uint32(ev.ByteSize), + TtlMs: uint32(ev.TTL / time.Millisecond), + WriteReason: string(ev.WriteReason), + Source: string(ev.Source), + })) + } + for i := range snapshot.FetchTimings { + ev := &snapshot.FetchTimings[i] + out = append(out, fillCommon(meta, now, cacheeventsv1.EventType_FETCH_TIMING, ev.EntityType, ev.DataSource, false, &cacheeventsv1.CacheEvent{ + FetchSource: fetchSourceFromGoTools(ev.Source), + DurationMs: float64(ev.DurationMs), + TtfbMs: float64(ev.TTFBMs), + ItemCount: uint32(ev.ItemCount), + IsEntityFetch: ev.IsEntityFetch, + HttpStatusCode: uint32(ev.HTTPStatusCode), + ResponseBytes: uint32(ev.ResponseBytes), + })) + } + for i := range snapshot.ErrorEvents { + ev := &snapshot.ErrorEvents[i] + out = append(out, fillCommon(meta, now, cacheeventsv1.EventType_SUBGRAPH_ERROR, ev.EntityType, ev.DataSource, false, &cacheeventsv1.CacheEvent{ + ErrorMessage: ev.Message, + ErrorCode: ev.Code, + })) + } + for i := range snapshot.ShadowComparisons { + ev := &snapshot.ShadowComparisons[i] + verdict := cacheeventsv1.Verdict_STALE + if ev.IsFresh { + verdict = cacheeventsv1.Verdict_FRESH + } + out = append(out, fillCommon(meta, now, cacheeventsv1.EventType_SHADOW_COMPARISON, ev.EntityType, ev.DataSource, true, &cacheeventsv1.CacheEvent{ + KeyHash: hashKey(ev.CacheKey), + Verdict: verdict, + ShadowIsFresh: ev.IsFresh, + CachedHash: ev.CachedHash, + FreshHash: ev.FreshHash, + CachedBytes: uint32(ev.CachedBytes), + FreshBytes: uint32(ev.FreshBytes), + CacheAgeMs: uint32(ev.CacheAgeMs), + ConfiguredTtlMs: uint32(ev.ConfiguredTTL / time.Millisecond), + })) + } + for i := range snapshot.MutationEvents { + ev := &snapshot.MutationEvents[i] + // MutationEvent has no DataSource in the pinned engine — pass empty. + out = append(out, fillCommon(meta, now, cacheeventsv1.EventType_MUTATION, ev.EntityType, "", false, &cacheeventsv1.CacheEvent{ + KeyHash: hashKey(ev.EntityCacheKey), + MutationRootField: ev.MutationRootField, + HadCachedValue: ev.HadCachedValue, + IsStale: ev.IsStale, + CachedHash: ev.CachedHash, + FreshHash: ev.FreshHash, + CachedBytes: uint32(ev.CachedBytes), + FreshBytes: uint32(ev.FreshBytes), + Source: string(ev.Source), + })) + } + for i := range snapshot.HeaderImpactEvents { + ev := &snapshot.HeaderImpactEvents[i] + out = append(out, fillCommon(meta, now, cacheeventsv1.EventType_HEADER_IMPACT, ev.EntityType, ev.DataSource, false, &cacheeventsv1.CacheEvent{ + BaseKeyHash: hashKey(ev.BaseKey), + HeaderHash: ev.HeaderHash, + ResponseHash: ev.ResponseHash, + })) + } + for i := range snapshot.CacheOpErrors { + ev := &snapshot.CacheOpErrors[i] + out = append(out, fillCommon(meta, now, cacheeventsv1.EventType_CACHE_OP_ERROR, ev.EntityType, ev.DataSource, false, &cacheeventsv1.CacheEvent{ + CacheOpKind: cacheOpKindFromString(ev.Operation), + CacheName: ev.CacheName, + ErrorMessage: ev.Message, + ItemCount: uint32(ev.ItemCount), + })) + } + for i := range snapshot.FieldHashes { + ev := &snapshot.FieldHashes[i] + // PII guard: only emit when the engine produced a hashed key. KeyRaw + // (raw entity key JSON) is never sent on the wire — the proto has no + // field for it, and we drop the event entirely if no KeyHash is set. + if ev.KeyHash == 0 { + continue + } + // EntityFieldHash has no DataSource or FieldPath in the pinned engine. + // Once those fields land upstream, populate them here. + out = append(out, fillCommon(meta, now, cacheeventsv1.EventType_FIELD_HASH, ev.EntityType, "", false, &cacheeventsv1.CacheEvent{ + KeyHash: ev.KeyHash, + FieldName: ev.FieldName, + FieldHash: ev.FieldHash, + FetchSource: fetchSourceFromGoTools(ev.Source), + })) + } + for i := range snapshot.EntityTypes { + ev := &snapshot.EntityTypes[i] + out = append(out, fillCommon(meta, now, cacheeventsv1.EventType_ENTITY_TYPE_INFO, ev.TypeName, "", false, &cacheeventsv1.CacheEvent{ + EntityCount: uint32(ev.Count), + EntityUniqueKeys: uint32(ev.UniqueKeys), + })) + } + return out +} + +// cacheOpKindFromString maps the engine's freeform Operation string onto the +// typed proto enum. Unknown values map to UNSPECIFIED (the writer will then +// fall back to the legacy string column). +func cacheOpKindFromString(op string) cacheeventsv1.CacheOpKind { + switch op { + case "get": + return cacheeventsv1.CacheOpKind_GET + case "set": + return cacheeventsv1.CacheOpKind_SET + case "set_negative": + return cacheeventsv1.CacheOpKind_SET_NEGATIVE + case "delete": + return cacheeventsv1.CacheOpKind_DELETE + default: + return cacheeventsv1.CacheOpKind_CACHE_OP_KIND_UNSPECIFIED + } +} + +// fillCommon populates the dimensions every event shares: timestamp, type, +// entity/subgraph identity, the operation/client/schema context. +func fillCommon(meta OperationMeta, ts uint64, t cacheeventsv1.EventType, entityType, subgraph string, isShadow bool, ev *cacheeventsv1.CacheEvent) *cacheeventsv1.CacheEvent { + ev.TimestampUnixNano = ts + ev.EventType = t + ev.OperationHash = meta.OperationHash + ev.OperationName = meta.OperationName + ev.OperationType = strings.ToLower(meta.OperationType) + ev.RouterConfigVersion = meta.RouterConfigVersion + ev.ClientName = meta.ClientName + ev.ClientVersion = meta.ClientVersion + ev.TraceId = meta.TraceID + ev.IsShadow = isShadow + ev.EntityType = entityType + ev.SubgraphId = subgraph + return ev +} + +// hashKey xxhashes a raw cache-key string. Returns 0 for empty input. +// This is the PII-redaction boundary: callers must not put the raw string +// onto a CacheEvent — only the hash. +func hashKey(s string) uint64 { + if s == "" { + return 0 + } + return xxhash.Sum64String(s) +} + +func verdictFromKind(k resolve.CacheKeyEventKind) cacheeventsv1.Verdict { + switch k { + case resolve.CacheKeyHit: + return cacheeventsv1.Verdict_HIT + case resolve.CacheKeyMiss: + return cacheeventsv1.Verdict_MISS + case resolve.CacheKeyPartialHit: + return cacheeventsv1.Verdict_PARTIAL_HIT + default: + return cacheeventsv1.Verdict_VERDICT_UNSPECIFIED + } +} + +func fetchSourceFromGoTools(s resolve.FieldSource) cacheeventsv1.FieldSource { + switch s { + case resolve.FieldSourceSubgraph: + return cacheeventsv1.FieldSource_SUBGRAPH + case resolve.FieldSourceL1: + return cacheeventsv1.FieldSource_L1 + case resolve.FieldSourceL2: + return cacheeventsv1.FieldSource_L2 + case resolve.FieldSourceShadowCached: + return cacheeventsv1.FieldSource_SHADOW_CACHED + default: + return cacheeventsv1.FieldSource_FIELD_SOURCE_UNSPECIFIED + } +} diff --git a/router/internal/cacheevents/builder_test.go b/router/internal/cacheevents/builder_test.go new file mode 100644 index 0000000000..05c5cd48ed --- /dev/null +++ b/router/internal/cacheevents/builder_test.go @@ -0,0 +1,428 @@ +package cacheevents + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + cacheeventsv1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/cacheevents/v1" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" +) + +func TestBuildEvents_NilSnapshot(t *testing.T) { + require.Nil(t, BuildEvents(nil, OperationMeta{})) +} + +func TestBuildEvents_EmptySnapshot(t *testing.T) { + require.Nil(t, BuildEvents(&resolve.CacheAnalyticsSnapshot{}, OperationMeta{})) +} + +func TestBuildEvents_AllEventTypes(t *testing.T) { + const piiKey = `{"id":"user-12345","email":"alice@example.com"}` + snap := &resolve.CacheAnalyticsSnapshot{ + L1Reads: []resolve.CacheKeyEvent{ + {CacheKey: piiKey, EntityType: "User", DataSource: "accounts", Kind: resolve.CacheKeyHit, ByteSize: 128}, + }, + L2Reads: []resolve.CacheKeyEvent{ + {CacheKey: piiKey, EntityType: "User", DataSource: "accounts", Kind: resolve.CacheKeyMiss}, + }, + L1Writes: []resolve.CacheWriteEvent{ + {CacheKey: piiKey, EntityType: "User", DataSource: "accounts", ByteSize: 128, TTL: 30 * time.Second, Source: resolve.CacheSourceQuery}, + }, + L2Writes: []resolve.CacheWriteEvent{ + {CacheKey: piiKey, EntityType: "User", DataSource: "accounts", ByteSize: 128, TTL: 60 * time.Second, Source: resolve.CacheSourceQuery}, + }, + FetchTimings: []resolve.FetchTimingEvent{ + {DataSource: "accounts", EntityType: "User", DurationMs: 12, Source: resolve.FieldSourceL2, ItemCount: 1, IsEntityFetch: true, HTTPStatusCode: 200, ResponseBytes: 128, TTFBMs: 1}, + }, + ErrorEvents: []resolve.SubgraphErrorEvent{ + {DataSource: "accounts", EntityType: "User", Message: "boom", Code: "INTERNAL_ERROR"}, + }, + ShadowComparisons: []resolve.ShadowComparisonEvent{ + {CacheKey: piiKey, EntityType: "User", DataSource: "accounts", IsFresh: false, CachedHash: 0xa, FreshHash: 0xb, CachedBytes: 100, FreshBytes: 110, CacheAgeMs: 5000, ConfiguredTTL: 60 * time.Second}, + }, + MutationEvents: []resolve.MutationEvent{ + {MutationRootField: "updateUser", EntityType: "User", EntityCacheKey: piiKey, HadCachedValue: true, IsStale: true, CachedHash: 0xa, FreshHash: 0xb, CachedBytes: 100, FreshBytes: 110, Source: resolve.CacheSourceMutation}, + }, + HeaderImpactEvents: []resolve.HeaderImpactEvent{ + {BaseKey: piiKey, HeaderHash: 0xa, ResponseHash: 0xb, EntityType: "User", DataSource: "accounts"}, + }, + CacheOpErrors: []resolve.CacheOperationError{ + {Operation: "get", CacheName: "redis", EntityType: "User", DataSource: "accounts", Message: "ECONNREFUSED", ItemCount: 1}, + }, + FieldHashes: []resolve.EntityFieldHash{ + // Safe: KeyHash is set (engine ran with HashAnalyticsKeys=true) — emit a FIELD_HASH event. + {EntityType: "User", FieldName: "email", FieldHash: 0xfeed, KeyHash: 0xdead, Source: resolve.FieldSourceL2}, + // PII guard: KeyHash is zero (HashAnalyticsKeys=false on this entity) — must NOT emit. + {EntityType: "User", FieldName: "phone", FieldHash: 0xbeef, KeyRaw: piiKey, Source: resolve.FieldSourceSubgraph}, + }, + EntityTypes: []resolve.EntityTypeInfo{ + {TypeName: "User", Count: 5, UniqueKeys: 3}, + }, + } + + meta := OperationMeta{ + OperationHash: "abc123", + OperationName: "GetUser", + OperationType: "QUERY", + RouterConfigVersion: "v1", + ClientName: "ios", + ClientVersion: "9.0.0", + TraceID: "00000000000000000000000000000001", + } + + events := BuildEvents(snap, meta) + // 10 base event types + 1 FIELD_HASH (the second has KeyHash=0 and is dropped) + 1 ENTITY_TYPE_INFO = 12. + require.Len(t, events, 12) + + // Verify each event type was emitted exactly once. + seen := map[cacheeventsv1.EventType]int{} + for _, ev := range events { + seen[ev.EventType]++ + } + for _, et := range []cacheeventsv1.EventType{ + cacheeventsv1.EventType_L1_READ, + cacheeventsv1.EventType_L2_READ, + cacheeventsv1.EventType_L1_WRITE, + cacheeventsv1.EventType_L2_WRITE, + cacheeventsv1.EventType_FETCH_TIMING, + cacheeventsv1.EventType_SUBGRAPH_ERROR, + cacheeventsv1.EventType_SHADOW_COMPARISON, + cacheeventsv1.EventType_MUTATION, + cacheeventsv1.EventType_HEADER_IMPACT, + cacheeventsv1.EventType_CACHE_OP_ERROR, + cacheeventsv1.EventType_FIELD_HASH, + cacheeventsv1.EventType_ENTITY_TYPE_INFO, + } { + require.Equalf(t, 1, seen[et], "missing event type %s", et) + } + + // PII guard: the raw cache key must NEVER appear on any string-valued + // proto field. The proto has no field for raw keys; only KeyHash and + // BaseKeyHash carry identity. This test guards against accidental + // reintroduction of a string-typed key field. + for _, ev := range events { + require.False(t, strings.Contains(ev.OperationHash, "user-12345")) + require.False(t, strings.Contains(ev.OperationName, "user-12345")) + require.False(t, strings.Contains(ev.EntityType, "user-12345")) + require.False(t, strings.Contains(ev.SubgraphId, "user-12345")) + require.False(t, strings.Contains(ev.WriteReason, "user-12345")) + require.False(t, strings.Contains(ev.Source, "user-12345")) + require.False(t, strings.Contains(ev.ErrorMessage, "user-12345")) + require.False(t, strings.Contains(ev.ErrorCode, "user-12345")) + require.False(t, strings.Contains(ev.MutationRootField, "user-12345")) + require.False(t, strings.Contains(ev.CacheOp, "user-12345")) + require.False(t, strings.Contains(ev.CacheName, "user-12345")) + require.False(t, strings.Contains(ev.TraceId, "user-12345")) + } + + // Verify KeyHash is non-zero where a key was supplied (read/write/shadow/mutation/field_hash), + // and zero where there was none (fetch_timing, subgraph_error, cache_op_error, entity_type_info). + for _, ev := range events { + switch ev.EventType { + case cacheeventsv1.EventType_L1_READ, + cacheeventsv1.EventType_L2_READ, + cacheeventsv1.EventType_L1_WRITE, + cacheeventsv1.EventType_L2_WRITE, + cacheeventsv1.EventType_SHADOW_COMPARISON, + cacheeventsv1.EventType_MUTATION, + cacheeventsv1.EventType_FIELD_HASH: + require.NotZerof(t, ev.KeyHash, "KeyHash must be set for %s", ev.EventType) + case cacheeventsv1.EventType_HEADER_IMPACT: + require.NotZero(t, ev.BaseKeyHash, "BaseKeyHash must be set for HEADER_IMPACT") + case cacheeventsv1.EventType_FETCH_TIMING, + cacheeventsv1.EventType_SUBGRAPH_ERROR, + cacheeventsv1.EventType_CACHE_OP_ERROR, + cacheeventsv1.EventType_ENTITY_TYPE_INFO: + require.Zerof(t, ev.KeyHash, "KeyHash must be zero for %s", ev.EventType) + } + } + + // FIELD_HASH must carry the engine-provided FieldName + FieldHash and the + // dropped (KeyHash=0) entry must NOT have produced an event. + for _, ev := range events { + if ev.EventType != cacheeventsv1.EventType_FIELD_HASH { + continue + } + require.Equal(t, "email", ev.FieldName) + require.Equal(t, uint64(0xfeed), ev.FieldHash) + require.Equal(t, uint64(0xdead), ev.KeyHash) + // SubgraphId on FIELD_HASH is sourced from EntityFieldHash.DataSource on + // the new engine; the pinned engine has no DataSource on that struct, so + // the field is left empty until the engine bump lands. + require.NotEqual(t, "phone", ev.FieldName, "FIELD_HASH event with KeyHash=0 must be dropped") + } + + // ENTITY_TYPE_INFO must carry counts. + for _, ev := range events { + if ev.EventType != cacheeventsv1.EventType_ENTITY_TYPE_INFO { + continue + } + require.Equal(t, "User", ev.EntityType) + require.Equal(t, uint32(5), ev.EntityCount) + require.Equal(t, uint32(3), ev.EntityUniqueKeys) + } + + // CacheOpKind must be populated on CACHE_OP_ERROR (engine string "get" → GET enum). + for _, ev := range events { + if ev.EventType != cacheeventsv1.EventType_CACHE_OP_ERROR { + continue + } + require.Equal(t, cacheeventsv1.CacheOpKind_GET, ev.CacheOpKind) + } + + // Operation context should propagate to every event. + for _, ev := range events { + require.Equal(t, "abc123", ev.OperationHash) + require.Equal(t, "GetUser", ev.OperationName) + require.Equal(t, "query", ev.OperationType, "must be lowercased") + require.Equal(t, "v1", ev.RouterConfigVersion) + require.Equal(t, "ios", ev.ClientName) + require.Equal(t, "9.0.0", ev.ClientVersion) + require.Equal(t, "00000000000000000000000000000001", ev.TraceId) + } +} + +func TestHashKey(t *testing.T) { + require.Zero(t, hashKey("")) + require.NotZero(t, hashKey("non-empty")) + require.Equal(t, hashKey("a"), hashKey("a")) + require.NotEqual(t, hashKey("a"), hashKey("b")) +} + +func TestCacheOpKindFromString(t *testing.T) { + t.Parallel() + + require.Equal(t, cacheeventsv1.CacheOpKind_GET, cacheOpKindFromString("get")) + require.Equal(t, cacheeventsv1.CacheOpKind_SET, cacheOpKindFromString("set")) + require.Equal(t, cacheeventsv1.CacheOpKind_SET_NEGATIVE, cacheOpKindFromString("set_negative")) + require.Equal(t, cacheeventsv1.CacheOpKind_DELETE, cacheOpKindFromString("delete")) + // Unknown values must fall through to UNSPECIFIED so the writer falls back + // to the legacy freeform string column. + require.Equal(t, cacheeventsv1.CacheOpKind_CACHE_OP_KIND_UNSPECIFIED, cacheOpKindFromString("")) + require.Equal(t, cacheeventsv1.CacheOpKind_CACHE_OP_KIND_UNSPECIFIED, cacheOpKindFromString("unknown")) + // Case sensitivity: the engine emits lower-case, anything else is unknown. + require.Equal(t, cacheeventsv1.CacheOpKind_CACHE_OP_KIND_UNSPECIFIED, cacheOpKindFromString("GET")) +} + +func TestVerdictFromKind(t *testing.T) { + t.Parallel() + + require.Equal(t, cacheeventsv1.Verdict_HIT, verdictFromKind(resolve.CacheKeyHit)) + require.Equal(t, cacheeventsv1.Verdict_MISS, verdictFromKind(resolve.CacheKeyMiss)) + require.Equal(t, cacheeventsv1.Verdict_PARTIAL_HIT, verdictFromKind(resolve.CacheKeyPartialHit)) + // Zero-value Kind (no matching case) must collapse to UNSPECIFIED so the + // rollup MV does not churn its LowCardinality dictionary on garbage input. + require.Equal(t, cacheeventsv1.Verdict_VERDICT_UNSPECIFIED, verdictFromKind(resolve.CacheKeyEventKind(0))) + require.Equal(t, cacheeventsv1.Verdict_VERDICT_UNSPECIFIED, verdictFromKind(resolve.CacheKeyEventKind(99))) +} + +func TestFetchSourceFromGoTools(t *testing.T) { + t.Parallel() + + require.Equal(t, cacheeventsv1.FieldSource_SUBGRAPH, fetchSourceFromGoTools(resolve.FieldSourceSubgraph)) + require.Equal(t, cacheeventsv1.FieldSource_L1, fetchSourceFromGoTools(resolve.FieldSourceL1)) + require.Equal(t, cacheeventsv1.FieldSource_L2, fetchSourceFromGoTools(resolve.FieldSourceL2)) + require.Equal(t, cacheeventsv1.FieldSource_SHADOW_CACHED, fetchSourceFromGoTools(resolve.FieldSourceShadowCached)) + require.Equal(t, cacheeventsv1.FieldSource_FIELD_SOURCE_UNSPECIFIED, fetchSourceFromGoTools(resolve.FieldSource(99))) +} + +func TestBuildEvents_OperationTypeIsLowercased(t *testing.T) { + t.Parallel() + + for _, in := range []string{"QUERY", "Mutation", "subscription", "MIXED case", ""} { + snap := &resolve.CacheAnalyticsSnapshot{ + L1Reads: []resolve.CacheKeyEvent{{CacheKey: "k", EntityType: "T", Kind: resolve.CacheKeyHit}}, + } + events := BuildEvents(snap, OperationMeta{OperationType: in}) + require.Len(t, events, 1) + require.Equal(t, strings.ToLower(in), events[0].OperationType, "input %q", in) + } +} + +func TestBuildEvents_SharesOneTimestampPerSnapshot(t *testing.T) { + t.Parallel() + + // All events from one snapshot must share a single timestamp — the + // pinned engine does not yet stamp per-event timestamps, and we want + // downstream consumers to see one consistent build-time value. + snap := &resolve.CacheAnalyticsSnapshot{ + L1Reads: []resolve.CacheKeyEvent{ + {CacheKey: "k1", EntityType: "User", Kind: resolve.CacheKeyHit}, + {CacheKey: "k2", EntityType: "User", Kind: resolve.CacheKeyMiss}, + }, + L2Writes: []resolve.CacheWriteEvent{ + {CacheKey: "k3", EntityType: "User", TTL: time.Second}, + }, + } + events := BuildEvents(snap, OperationMeta{}) + require.Len(t, events, 3) + ts := events[0].TimestampUnixNano + require.NotZero(t, ts) + for i, ev := range events { + require.Equalf(t, ts, ev.TimestampUnixNano, "event[%d] must share the snapshot timestamp", i) + } +} + +func TestBuildEvents_FieldLevelMappings(t *testing.T) { + t.Parallel() + + const cacheKey = "User:1" + snap := &resolve.CacheAnalyticsSnapshot{ + L1Reads: []resolve.CacheKeyEvent{ + {CacheKey: cacheKey, EntityType: "User", DataSource: "accounts", Kind: resolve.CacheKeyHit, ByteSize: 256, CacheAgeMs: 1500, Shadow: true}, + }, + L1Writes: []resolve.CacheWriteEvent{ + {CacheKey: cacheKey, EntityType: "User", DataSource: "accounts", ByteSize: 1024, TTL: 90 * time.Second, Source: resolve.CacheSourceMutation, WriteReason: "refresh"}, + }, + FetchTimings: []resolve.FetchTimingEvent{ + {DataSource: "accounts", EntityType: "User", DurationMs: 42, TTFBMs: 7, Source: resolve.FieldSourceSubgraph, ItemCount: 3, IsEntityFetch: true, HTTPStatusCode: 503, ResponseBytes: 9001}, + }, + ShadowComparisons: []resolve.ShadowComparisonEvent{ + {CacheKey: cacheKey, EntityType: "User", DataSource: "accounts", IsFresh: true, CachedHash: 0x11, FreshHash: 0x22, CachedBytes: 50, FreshBytes: 60, CacheAgeMs: 100, ConfiguredTTL: 30 * time.Second}, + }, + MutationEvents: []resolve.MutationEvent{ + {MutationRootField: "updateUser", EntityType: "User", EntityCacheKey: cacheKey, HadCachedValue: false, IsStale: false, Source: resolve.CacheSourceMutation}, + }, + HeaderImpactEvents: []resolve.HeaderImpactEvent{ + {BaseKey: cacheKey, HeaderHash: 0xaa, ResponseHash: 0xbb, EntityType: "User", DataSource: "accounts"}, + }, + CacheOpErrors: []resolve.CacheOperationError{ + {Operation: "delete", CacheName: "redis", EntityType: "User", DataSource: "accounts", Message: "ECONNREFUSED", ItemCount: 4}, + }, + } + events := BuildEvents(snap, OperationMeta{}) + byType := map[cacheeventsv1.EventType]*cacheeventsv1.CacheEvent{} + for _, ev := range events { + byType[ev.EventType] = ev + } + + read := byType[cacheeventsv1.EventType_L1_READ] + require.NotNil(t, read) + require.Equal(t, cacheeventsv1.Verdict_HIT, read.Verdict) + require.Equal(t, uint32(256), read.ByteSize) + require.Equal(t, uint32(1500), read.CacheAgeMs) + require.True(t, read.IsShadow, "Shadow flag must propagate from CacheKeyEvent.Shadow") + require.Equal(t, "accounts", read.SubgraphId, "DataSource must populate SubgraphId") + + write := byType[cacheeventsv1.EventType_L1_WRITE] + require.NotNil(t, write) + require.Equal(t, uint32(1024), write.ByteSize) + require.Equal(t, uint32(90_000), write.TtlMs, "TTL must convert to milliseconds") + require.Equal(t, "mutation", write.Source, "CacheOperationSource string passes through unchanged") + require.Equal(t, "refresh", write.WriteReason) + + timing := byType[cacheeventsv1.EventType_FETCH_TIMING] + require.NotNil(t, timing) + require.InDelta(t, 42.0, timing.DurationMs, 0.0) + require.InDelta(t, 7.0, timing.TtfbMs, 0.0) + require.Equal(t, uint32(3), timing.ItemCount) + require.True(t, timing.IsEntityFetch) + require.Equal(t, uint32(503), timing.HttpStatusCode) + require.Equal(t, uint32(9001), timing.ResponseBytes) + require.Equal(t, cacheeventsv1.FieldSource_SUBGRAPH, timing.FetchSource) + // FETCH_TIMING never carries a key — the proto has no CacheKey on this event. + require.Zero(t, timing.KeyHash) + + shadow := byType[cacheeventsv1.EventType_SHADOW_COMPARISON] + require.NotNil(t, shadow) + require.Equal(t, cacheeventsv1.Verdict_FRESH, shadow.Verdict, "IsFresh=true must map to FRESH") + require.True(t, shadow.ShadowIsFresh) + require.Equal(t, uint64(0x11), shadow.CachedHash) + require.Equal(t, uint64(0x22), shadow.FreshHash) + require.Equal(t, uint32(30_000), shadow.ConfiguredTtlMs) + require.True(t, shadow.IsShadow, "SHADOW_COMPARISON must always carry IsShadow=true") + + mutation := byType[cacheeventsv1.EventType_MUTATION] + require.NotNil(t, mutation) + require.Equal(t, "updateUser", mutation.MutationRootField) + require.False(t, mutation.HadCachedValue) + require.Empty(t, mutation.SubgraphId, "MutationEvent has no DataSource on the pinned engine") + + header := byType[cacheeventsv1.EventType_HEADER_IMPACT] + require.NotNil(t, header) + require.NotZero(t, header.BaseKeyHash, "BaseKey must be hashed onto BaseKeyHash") + require.Equal(t, uint64(0xaa), header.HeaderHash) + require.Equal(t, uint64(0xbb), header.ResponseHash) + + opErr := byType[cacheeventsv1.EventType_CACHE_OP_ERROR] + require.NotNil(t, opErr) + require.Equal(t, cacheeventsv1.CacheOpKind_DELETE, opErr.CacheOpKind) + require.Equal(t, "redis", opErr.CacheName) + require.Equal(t, "ECONNREFUSED", opErr.ErrorMessage) + require.Equal(t, uint32(4), opErr.ItemCount) +} + +func TestBuildEvents_VerdictMapsFromKind(t *testing.T) { + t.Parallel() + + cases := map[resolve.CacheKeyEventKind]cacheeventsv1.Verdict{ + resolve.CacheKeyHit: cacheeventsv1.Verdict_HIT, + resolve.CacheKeyMiss: cacheeventsv1.Verdict_MISS, + resolve.CacheKeyPartialHit: cacheeventsv1.Verdict_PARTIAL_HIT, + } + for kind, want := range cases { + snap := &resolve.CacheAnalyticsSnapshot{ + L2Reads: []resolve.CacheKeyEvent{{CacheKey: "k", EntityType: "T", Kind: kind}}, + } + events := BuildEvents(snap, OperationMeta{}) + require.Len(t, events, 1) + require.Equalf(t, want, events[0].Verdict, "kind=%v", kind) + } +} + +func TestBuildEvents_ShadowComparison_StaleVerdictWhenNotFresh(t *testing.T) { + t.Parallel() + + snap := &resolve.CacheAnalyticsSnapshot{ + ShadowComparisons: []resolve.ShadowComparisonEvent{ + {CacheKey: "k", EntityType: "T", IsFresh: false}, + }, + } + events := BuildEvents(snap, OperationMeta{}) + require.Len(t, events, 1) + require.Equal(t, cacheeventsv1.Verdict_STALE, events[0].Verdict) + require.False(t, events[0].ShadowIsFresh) + require.True(t, events[0].IsShadow) +} + +func TestBuildEvents_FieldHash_DropsWhenKeyHashIsZero(t *testing.T) { + t.Parallel() + + snap := &resolve.CacheAnalyticsSnapshot{ + FieldHashes: []resolve.EntityFieldHash{ + {EntityType: "User", FieldName: "email", FieldHash: 0xfeed, KeyHash: 0xdead, Source: resolve.FieldSourceL2}, + // PII guard: when the engine did not hash the key, KeyRaw might + // hold the raw entity-key JSON. The proto has no field for raw + // keys, so we drop the event entirely rather than risk leakage. + {EntityType: "User", FieldName: "phone", FieldHash: 0xbeef, KeyHash: 0, KeyRaw: `{"id":"1"}`, Source: resolve.FieldSourceSubgraph}, + {EntityType: "User", FieldName: "ssn", FieldHash: 0xcafe, KeyHash: 0, Source: resolve.FieldSourceSubgraph}, + }, + } + events := BuildEvents(snap, OperationMeta{}) + require.Len(t, events, 1, "only the entry with non-zero KeyHash must produce a FIELD_HASH event") + require.Equal(t, cacheeventsv1.EventType_FIELD_HASH, events[0].EventType) + require.Equal(t, "email", events[0].FieldName) + require.Equal(t, uint64(0xfeed), events[0].FieldHash) + require.Equal(t, uint64(0xdead), events[0].KeyHash) +} + +func TestBuildEvents_EntityTypeInfo_NoKeyOrSubgraph(t *testing.T) { + t.Parallel() + + snap := &resolve.CacheAnalyticsSnapshot{ + EntityTypes: []resolve.EntityTypeInfo{ + {TypeName: "User", Count: 7, UniqueKeys: 4}, + }, + } + events := BuildEvents(snap, OperationMeta{}) + require.Len(t, events, 1) + ev := events[0] + require.Equal(t, cacheeventsv1.EventType_ENTITY_TYPE_INFO, ev.EventType) + require.Equal(t, "User", ev.EntityType) + require.Equal(t, uint32(7), ev.EntityCount) + require.Equal(t, uint32(4), ev.EntityUniqueKeys) + require.Empty(t, ev.SubgraphId, "ENTITY_TYPE_INFO has no subgraph dimension") + require.Zero(t, ev.KeyHash, "ENTITY_TYPE_INFO carries no key") +} diff --git a/router/internal/cacheevents/exporter.go b/router/internal/cacheevents/exporter.go new file mode 100644 index 0000000000..9016e67140 --- /dev/null +++ b/router/internal/cacheevents/exporter.go @@ -0,0 +1,18 @@ +package cacheevents + +import ( + cacheeventsv1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/cacheevents/v1" + "github.com/wundergraph/cosmo/router/internal/exporter" + "go.uber.org/zap" +) + +// Exporter is the type alias used by callers; it is a thin wrapper around +// the generic batched async exporter. +type Exporter = exporter.Exporter[*cacheeventsv1.CacheEvent] + +// NewExporter constructs the cache-events exporter. The sink is responsible +// for the actual Connect call; this exporter handles queueing, batching, +// retry, and graceful shutdown. +func NewExporter(logger *zap.Logger, sink *Sink, settings *exporter.ExporterSettings) (*Exporter, error) { + return exporter.NewExporter(logger, sink, exporter.IsRetryableConnectError, settings) +} diff --git a/router/internal/cacheevents/exporter_test.go b/router/internal/cacheevents/exporter_test.go new file mode 100644 index 0000000000..59bc823d0e --- /dev/null +++ b/router/internal/cacheevents/exporter_test.go @@ -0,0 +1,41 @@ +package cacheevents + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/cacheevents/v1/cacheeventsv1connect" + "github.com/wundergraph/cosmo/router/internal/exporter" + "go.uber.org/zap" +) + +func TestNewExporter_ConstructsAndShutsDown(t *testing.T) { + t.Parallel() + + client := cacheeventsv1connect.NewCacheEventsServiceClient(http.DefaultClient, "http://localhost:0") + sink := NewSink(SinkConfig{Client: client, Logger: zap.NewNop()}) + + exp, err := NewExporter(zap.NewNop(), sink, exporter.NewDefaultExporterSettings()) + require.NoError(t, err) + require.NotNil(t, exp) + + // Shutdown must be a clean no-op when nothing was recorded. + require.NoError(t, exp.Shutdown(context.Background())) +} + +func TestNewExporter_RejectsBadSettings(t *testing.T) { + t.Parallel() + + client := cacheeventsv1connect.NewCacheEventsServiceClient(http.DefaultClient, "http://localhost:0") + sink := NewSink(SinkConfig{Client: client, Logger: zap.NewNop()}) + + // Settings validation lives on the generic exporter — surface it here so + // we know the wrapper's contract: invalid settings produce an error. + bad := *exporter.NewDefaultExporterSettings() + bad.BatchSize = 0 + bad.QueueSize = 0 + _, err := NewExporter(zap.NewNop(), sink, &bad) + require.Error(t, err, "exporter must reject zero-sized batch/queue") +} diff --git a/router/internal/cacheevents/sink.go b/router/internal/cacheevents/sink.go new file mode 100644 index 0000000000..265d9d3c06 --- /dev/null +++ b/router/internal/cacheevents/sink.go @@ -0,0 +1,47 @@ +package cacheevents + +import ( + "context" + + "connectrpc.com/connect" + cacheeventsv1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/cacheevents/v1" + "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/cacheevents/v1/cacheeventsv1connect" + "go.uber.org/zap" +) + +// Sink ships batches of CacheEvent records to the cosmo cache-events endpoint +// over Connect/gRPC. +type Sink struct { + client cacheeventsv1connect.CacheEventsServiceClient + logger *zap.Logger +} + +// SinkConfig is the constructor input for Sink. +type SinkConfig struct { + Client cacheeventsv1connect.CacheEventsServiceClient + Logger *zap.Logger +} + +// NewSink wraps a Connect client into a router exporter.Sink. +func NewSink(cfg SinkConfig) *Sink { + return &Sink{ + client: cfg.Client, + logger: cfg.Logger.With(zap.String("component", "cache_events_sink")), + } +} + +// Export sends the batch via PublishEntityCacheEvents. +func (s *Sink) Export(ctx context.Context, batch []*cacheeventsv1.CacheEvent) error { + if len(batch) == 0 { + return nil + } + if _, err := s.client.PublishEntityCacheEvents(ctx, connect.NewRequest(BuildRequest(batch))); err != nil { + s.logger.Debug("Failed to export cache events batch", zap.Error(err), zap.Int("size", len(batch))) + return err + } + s.logger.Debug("Cache events batch exported", zap.Int("size", len(batch))) + return nil +} + +// Close is a no-op — the underlying Connect client has nothing to clean up. +func (s *Sink) Close(ctx context.Context) error { return nil } diff --git a/router/internal/cacheevents/sink_test.go b/router/internal/cacheevents/sink_test.go new file mode 100644 index 0000000000..7cdc7f73aa --- /dev/null +++ b/router/internal/cacheevents/sink_test.go @@ -0,0 +1,102 @@ +package cacheevents + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "connectrpc.com/connect" + "github.com/stretchr/testify/require" + cacheeventsv1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/cacheevents/v1" + "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/cacheevents/v1/cacheeventsv1connect" + "go.uber.org/zap" +) + +type recordingHandler struct { + cacheeventsv1connect.UnimplementedCacheEventsServiceHandler + + mu sync.Mutex + auth []string + batches [][]*cacheeventsv1.CacheEvent +} + +func (h *recordingHandler) PublishEntityCacheEvents( + _ context.Context, + req *connect.Request[cacheeventsv1.PublishEntityCacheEventsRequest], +) (*connect.Response[cacheeventsv1.PublishEntityCacheEventsResponse], error) { + h.mu.Lock() + h.auth = append(h.auth, req.Header().Get("Authorization")) + h.batches = append(h.batches, req.Msg.GetEvents()) + h.mu.Unlock() + return connect.NewResponse(&cacheeventsv1.PublishEntityCacheEventsResponse{}), nil +} + +func newRecordingServer(t *testing.T) (*recordingHandler, string) { + t.Helper() + handler := &recordingHandler{} + mux := http.NewServeMux() + path, h := cacheeventsv1connect.NewCacheEventsServiceHandler(handler) + mux.Handle(path, h) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return handler, srv.URL +} + +// TestSink_Export_DoesNotSetAuthHeader is the sink-level "before/after" +// assertion: the sink no longer sets Authorization itself. Auth is the +// Connect interceptor's responsibility now (see internal/exporter.WithBearerAuth). +// A client constructed without WithBearerAuth must produce an empty header. +func TestSink_Export_DoesNotSetAuthHeader(t *testing.T) { + t.Parallel() + handler, url := newRecordingServer(t) + + client := cacheeventsv1connect.NewCacheEventsServiceClient(http.DefaultClient, url) + sink := NewSink(SinkConfig{Client: client, Logger: zap.NewNop()}) + + batch := []*cacheeventsv1.CacheEvent{{EventType: cacheeventsv1.EventType_L1_READ}} + require.NoError(t, sink.Export(context.Background(), batch)) + + handler.mu.Lock() + defer handler.mu.Unlock() + require.Equal(t, []string{""}, handler.auth) +} + +func TestSink_Export_ForwardsBatch(t *testing.T) { + t.Parallel() + handler, url := newRecordingServer(t) + + client := cacheeventsv1connect.NewCacheEventsServiceClient(http.DefaultClient, url) + sink := NewSink(SinkConfig{Client: client, Logger: zap.NewNop()}) + + batch := []*cacheeventsv1.CacheEvent{ + {EventType: cacheeventsv1.EventType_L1_READ, EntityType: "User"}, + {EventType: cacheeventsv1.EventType_L2_WRITE, EntityType: "Product"}, + } + require.NoError(t, sink.Export(context.Background(), batch)) + + handler.mu.Lock() + defer handler.mu.Unlock() + require.Len(t, handler.batches, 1) + require.Len(t, handler.batches[0], 2) + require.Equal(t, cacheeventsv1.EventType_L1_READ, handler.batches[0][0].EventType) + require.Equal(t, "User", handler.batches[0][0].EntityType) + require.Equal(t, cacheeventsv1.EventType_L2_WRITE, handler.batches[0][1].EventType) + require.Equal(t, "Product", handler.batches[0][1].EntityType) +} + +func TestSink_Export_EmptyBatchIsNoOp(t *testing.T) { + t.Parallel() + handler, url := newRecordingServer(t) + + client := cacheeventsv1connect.NewCacheEventsServiceClient(http.DefaultClient, url) + sink := NewSink(SinkConfig{Client: client, Logger: zap.NewNop()}) + + require.NoError(t, sink.Export(context.Background(), nil)) + require.NoError(t, sink.Export(context.Background(), []*cacheeventsv1.CacheEvent{})) + + handler.mu.Lock() + defer handler.mu.Unlock() + require.Empty(t, handler.batches, "empty batches must not hit the wire") +} diff --git a/router/internal/exporter/auth.go b/router/internal/exporter/auth.go new file mode 100644 index 0000000000..c93f034824 --- /dev/null +++ b/router/internal/exporter/auth.go @@ -0,0 +1,21 @@ +package exporter + +import ( + "context" + "fmt" + + "connectrpc.com/connect" +) + +// WithBearerAuth returns a Connect client option that adds an +// `Authorization: Bearer ` header to every unary request. +func WithBearerAuth(token string) connect.ClientOption { + return connect.WithInterceptors( + connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + req.Header().Set("Authorization", fmt.Sprintf("Bearer %s", token)) + return next(ctx, req) + } + }), + ) +} diff --git a/router/internal/exporter/auth_test.go b/router/internal/exporter/auth_test.go new file mode 100644 index 0000000000..1fb4873656 --- /dev/null +++ b/router/internal/exporter/auth_test.go @@ -0,0 +1,129 @@ +package exporter + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "connectrpc.com/connect" + "github.com/stretchr/testify/require" + graphqlmetricsv1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/graphqlmetrics/v1" + "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/graphqlmetrics/v1/graphqlmetricsv1connect" +) + +type bearerAuthHandler struct { + graphqlmetricsv1connect.UnimplementedGraphQLMetricsServiceHandler + + mu sync.Mutex + auth []string +} + +func (h *bearerAuthHandler) PublishAggregatedGraphQLMetrics( + _ context.Context, + req *connect.Request[graphqlmetricsv1.PublishAggregatedGraphQLRequestMetricsRequest], +) (*connect.Response[graphqlmetricsv1.PublishAggregatedGraphQLRequestMetricsResponse], error) { + h.mu.Lock() + h.auth = append(h.auth, req.Header().Get("Authorization")) + h.mu.Unlock() + return connect.NewResponse(&graphqlmetricsv1.PublishAggregatedGraphQLRequestMetricsResponse{}), nil +} + +func (h *bearerAuthHandler) snapshot() []string { + h.mu.Lock() + defer h.mu.Unlock() + out := make([]string, len(h.auth)) + copy(out, h.auth) + return out +} + +func newBearerAuthServer(t *testing.T) (*bearerAuthHandler, *httptest.Server) { + t.Helper() + handler := &bearerAuthHandler{} + mux := http.NewServeMux() + path, h := graphqlmetricsv1connect.NewGraphQLMetricsServiceHandler(handler) + mux.Handle(path, h) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return handler, srv +} + +func publish(t *testing.T, client graphqlmetricsv1connect.GraphQLMetricsServiceClient) { + t.Helper() + _, err := client.PublishAggregatedGraphQLMetrics( + context.Background(), + connect.NewRequest(&graphqlmetricsv1.PublishAggregatedGraphQLRequestMetricsRequest{}), + ) + require.NoError(t, err) +} + +func TestWithBearerAuth_SetsAuthorizationHeader(t *testing.T) { + t.Parallel() + handler, srv := newBearerAuthServer(t) + + client := graphqlmetricsv1connect.NewGraphQLMetricsServiceClient( + srv.Client(), + srv.URL, + WithBearerAuth("secret-token"), + ) + + publish(t, client) + require.Equal(t, []string{"Bearer secret-token"}, handler.snapshot()) +} + +// TestWithBearerAuth_AppliesToEveryCall is the "after" half of the before/after +// API-key validation: previously the sink set the Authorization header on +// every call manually; now the interceptor must do the same automatically. +func TestWithBearerAuth_AppliesToEveryCall(t *testing.T) { + t.Parallel() + handler, srv := newBearerAuthServer(t) + + client := graphqlmetricsv1connect.NewGraphQLMetricsServiceClient( + srv.Client(), + srv.URL, + WithBearerAuth("secret-token"), + ) + + for range 3 { + publish(t, client) + } + require.Equal(t, []string{ + "Bearer secret-token", + "Bearer secret-token", + "Bearer secret-token", + }, handler.snapshot()) +} + +// TestWithoutBearerAuth_NoAuthorizationHeader proves the header is set by the +// interceptor and nothing else: a client constructed without WithBearerAuth +// produces no Authorization header. This is the "before" assertion that +// confirms the sink itself never sets the header in the new design. +func TestWithoutBearerAuth_NoAuthorizationHeader(t *testing.T) { + t.Parallel() + handler, srv := newBearerAuthServer(t) + + client := graphqlmetricsv1connect.NewGraphQLMetricsServiceClient( + srv.Client(), + srv.URL, + ) + + publish(t, client) + require.Equal(t, []string{""}, handler.snapshot()) +} + +func TestWithBearerAuth_EmptyToken(t *testing.T) { + t.Parallel() + handler, srv := newBearerAuthServer(t) + + client := graphqlmetricsv1connect.NewGraphQLMetricsServiceClient( + srv.Client(), + srv.URL, + WithBearerAuth(""), + ) + + publish(t, client) + // HTTP transport trims trailing whitespace from header values; the + // interceptor still sends "Bearer ", but the server observes "Bearer". + require.Equal(t, []string{"Bearer"}, handler.snapshot()) +} diff --git a/router/internal/exporter/retry.go b/router/internal/exporter/retry.go new file mode 100644 index 0000000000..92e2047fca --- /dev/null +++ b/router/internal/exporter/retry.go @@ -0,0 +1,28 @@ +package exporter + +import ( + "errors" + + "connectrpc.com/connect" +) + +// IsRetryableConnectError returns false for Connect errors that indicate a +// permanent failure (bad credentials or bad input), true otherwise. Errors +// that are not *connect.Error are treated as retryable by default. +func IsRetryableConnectError(err error) bool { + if err == nil { + return false + } + var connectErr *connect.Error + if errors.As(err, &connectErr) { + switch connectErr.Code() { + case connect.CodeUnauthenticated, + connect.CodePermissionDenied, + connect.CodeInvalidArgument: + return false + default: + return true + } + } + return true +} diff --git a/router/internal/exporter/retry_test.go b/router/internal/exporter/retry_test.go new file mode 100644 index 0000000000..b1d6a2a159 --- /dev/null +++ b/router/internal/exporter/retry_test.go @@ -0,0 +1,54 @@ +package exporter + +import ( + "errors" + "io" + "testing" + + "connectrpc.com/connect" + "github.com/stretchr/testify/require" +) + +func TestIsRetryableConnectError(t *testing.T) { + t.Parallel() + + t.Run("nil is not retryable", func(t *testing.T) { + require.False(t, IsRetryableConnectError(nil)) + }) + + t.Run("non-connect error is retryable", func(t *testing.T) { + require.True(t, IsRetryableConnectError(errors.New("plain error"))) + require.True(t, IsRetryableConnectError(io.ErrUnexpectedEOF)) + }) + + t.Run("permanent connect codes are not retryable", func(t *testing.T) { + for _, code := range []connect.Code{ + connect.CodeUnauthenticated, + connect.CodePermissionDenied, + connect.CodeInvalidArgument, + } { + err := connect.NewError(code, errors.New("nope")) + require.False(t, IsRetryableConnectError(err), "code=%s", code) + } + }) + + t.Run("transient connect codes are retryable", func(t *testing.T) { + for _, code := range []connect.Code{ + connect.CodeUnavailable, + connect.CodeDeadlineExceeded, + connect.CodeInternal, + connect.CodeResourceExhausted, + connect.CodeAborted, + connect.CodeUnknown, + } { + err := connect.NewError(code, errors.New("retry me")) + require.True(t, IsRetryableConnectError(err), "code=%s", code) + } + }) + + t.Run("wrapped permanent code unwraps via errors.As", func(t *testing.T) { + inner := connect.NewError(connect.CodeUnauthenticated, errors.New("token expired")) + wrapped := errors.Join(errors.New("export failed"), inner) + require.False(t, IsRetryableConnectError(wrapped)) + }) +} diff --git a/router/internal/graphqlmetrics/graphql_exporter.go b/router/internal/graphqlmetrics/graphql_exporter.go index 67430ff718..c7bdb0c44d 100644 --- a/router/internal/graphqlmetrics/graphql_exporter.go +++ b/router/internal/graphqlmetrics/graphql_exporter.go @@ -20,30 +20,28 @@ type GraphQLMetricsExporter struct { func NewGraphQLMetricsExporter( logger *zap.Logger, client graphqlmetricsv1connect.GraphQLMetricsServiceClient, - apiToken string, settings *exporter.ExporterSettings, ) (*GraphQLMetricsExporter, error) { - sink := NewGraphQLMetricsSink(GraphQLMetricsSinkConfig{ - Client: client, - APIToken: apiToken, - Logger: logger, - }) - if logger == nil { logger = zap.NewNop() } + sink := NewGraphQLMetricsSink(GraphQLMetricsSinkConfig{ + Client: client, + Logger: logger, + }) + if settings == nil { settings = exporter.NewDefaultExporterSettings() } - exporter, err := exporter.NewExporter(logger, sink, IsRetryableError, settings) + exp, err := exporter.NewExporter(logger, sink, exporter.IsRetryableConnectError, settings) if err != nil { return nil, err } return &GraphQLMetricsExporter{ - exporter: exporter, + exporter: exp, }, nil } diff --git a/router/internal/graphqlmetrics/graphql_exporter_test.go b/router/internal/graphqlmetrics/graphql_exporter_test.go index b0ba6bbe6e..c32467adfb 100644 --- a/router/internal/graphqlmetrics/graphql_exporter_test.go +++ b/router/internal/graphqlmetrics/graphql_exporter_test.go @@ -26,7 +26,6 @@ type MyClient struct { func (m *MyClient) PublishAggregatedGraphQLMetrics(ctx context.Context, c *connect.Request[graphqlmetricsv1.PublishAggregatedGraphQLRequestMetricsRequest]) (*connect.Response[graphqlmetricsv1.PublishAggregatedGraphQLRequestMetricsResponse], error) { m.mu.Lock() defer m.mu.Unlock() - require.Equal(m.t, "Bearer secret", c.Header().Get("Authorization")) m.publishedAggregations = append(m.publishedAggregations, c.Msg.Aggregation) return nil, nil } @@ -34,7 +33,6 @@ func (m *MyClient) PublishAggregatedGraphQLMetrics(ctx context.Context, c *conne func (m *MyClient) PublishGraphQLMetrics(ctx context.Context, c *connect.Request[graphqlmetricsv1.PublishGraphQLRequestMetricsRequest]) (*connect.Response[graphqlmetricsv1.PublishOperationCoverageReportResponse], error) { m.mu.Lock() defer m.mu.Unlock() - require.Equal(m.t, "Bearer secret", c.Header().Get("Authorization")) m.publishedBatches = append(m.publishedBatches, c.Msg.GetSchemaUsage()) return nil, nil } @@ -53,7 +51,6 @@ func TestExportAggregationSameSchemaUsages(t *testing.T) { e, err := NewGraphQLMetricsExporter( zap.NewNop(), c, - "secret", &exporter.ExporterSettings{ BatchSize: batchSize, QueueSize: queueSize, @@ -134,7 +131,6 @@ func TestExportBatchesWithUniqueSchemaUsages(t *testing.T) { e, err := NewGraphQLMetricsExporter( zap.NewNop(), c, - "secret", &exporter.ExporterSettings{ BatchSize: batchSize, QueueSize: queueSize, @@ -205,7 +201,6 @@ func TestForceFlushSync(t *testing.T) { e, err := NewGraphQLMetricsExporter( zap.NewNop(), c, - "secret", &exporter.ExporterSettings{ BatchSize: batchSize, QueueSize: queueSize, @@ -328,7 +323,6 @@ func TestExportBatchInterval(t *testing.T) { e, err := NewGraphQLMetricsExporter( zap.NewNop(), c, - "secret", &exporter.ExporterSettings{ BatchSize: batchSize, QueueSize: queueSize, @@ -404,7 +398,6 @@ func TestExportFullQueue(t *testing.T) { e, err := NewGraphQLMetricsExporter( zap.NewNop(), c, - "secret", &exporter.ExporterSettings{ BatchSize: batchSize, QueueSize: queueSize, diff --git a/router/internal/graphqlmetrics/graphql_metrics_sink.go b/router/internal/graphqlmetrics/graphql_metrics_sink.go index 1929ded6de..67441ba8c5 100644 --- a/router/internal/graphqlmetrics/graphql_metrics_sink.go +++ b/router/internal/graphqlmetrics/graphql_metrics_sink.go @@ -2,8 +2,6 @@ package graphqlmetrics import ( "context" - "errors" - "fmt" "connectrpc.com/connect" graphqlmetrics "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/graphqlmetrics/v1" @@ -14,24 +12,21 @@ import ( // GraphQLMetricsSink implements the Sink interface for sending aggregated GraphQL metrics // to the Cosmo GraphQL Metrics Service via Connect RPC. type GraphQLMetricsSink struct { - client graphqlmetricsv1connect.GraphQLMetricsServiceClient - apiToken string - logger *zap.Logger + client graphqlmetricsv1connect.GraphQLMetricsServiceClient + logger *zap.Logger } // GraphQLMetricsSinkConfig contains configuration for creating a GraphQLMetricsSink. type GraphQLMetricsSinkConfig struct { - Client graphqlmetricsv1connect.GraphQLMetricsServiceClient - APIToken string - Logger *zap.Logger + Client graphqlmetricsv1connect.GraphQLMetricsServiceClient + Logger *zap.Logger } // NewGraphQLMetricsSink creates a new sink that sends metrics to the GraphQL Metrics Service. func NewGraphQLMetricsSink(cfg GraphQLMetricsSinkConfig) *GraphQLMetricsSink { return &GraphQLMetricsSink{ - client: cfg.Client, - apiToken: cfg.APIToken, - logger: cfg.Logger.With(zap.String("component", "graphql_metrics_sink")), + client: cfg.Client, + logger: cfg.Logger.With(zap.String("component", "graphql_metrics_sink")), } } @@ -47,11 +42,7 @@ func (s *GraphQLMetricsSink) Export(ctx context.Context, batch []*graphqlmetrics // Aggregate the batch to reduce payload size request := AggregateSchemaUsageInfoBatch(batch) - req := connect.NewRequest(request) - req.Header().Set("Authorization", fmt.Sprintf("Bearer %s", s.apiToken)) - - _, err := s.client.PublishAggregatedGraphQLMetrics(ctx, req) - if err != nil { + if _, err := s.client.PublishAggregatedGraphQLMetrics(ctx, connect.NewRequest(request)); err != nil { s.logger.Debug("Failed to export batch", zap.Error(err), zap.Int("batch_size", len(request.Aggregation))) return err } @@ -66,26 +57,3 @@ func (s *GraphQLMetricsSink) Close(ctx context.Context) error { s.logger.Debug("Closing GraphQL metrics sink") return nil } - -// IsRetryableError determines if an error from the GraphQL Metrics Service is retryable. -// Authentication errors should not be retried, while network and server errors should be. -func IsRetryableError(err error) bool { - if err == nil { - return false - } - - var connectErr *connect.Error - if errors.As(err, &connectErr) { - switch connectErr.Code() { - case connect.CodeUnauthenticated, connect.CodePermissionDenied, connect.CodeInvalidArgument: - // Don't retry authentication, authorization, or validation errors - return false - default: - // Retry other errors (network issues, server errors, etc.) - return true - } - } - - // Unknown errors are retryable by default - return true -} diff --git a/router/internal/graphqlmetrics/graphql_metrics_sink_test.go b/router/internal/graphqlmetrics/graphql_metrics_sink_test.go new file mode 100644 index 0000000000..8fc61f716b --- /dev/null +++ b/router/internal/graphqlmetrics/graphql_metrics_sink_test.go @@ -0,0 +1,110 @@ +package graphqlmetrics + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "connectrpc.com/connect" + "github.com/stretchr/testify/require" + graphqlmetricsv1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/graphqlmetrics/v1" + "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/graphqlmetrics/v1/graphqlmetricsv1connect" + "go.uber.org/zap" +) + +type recordingHandler struct { + graphqlmetricsv1connect.UnimplementedGraphQLMetricsServiceHandler + + mu sync.Mutex + auth []string + aggregate []*graphqlmetricsv1.PublishAggregatedGraphQLRequestMetricsRequest +} + +func (h *recordingHandler) PublishAggregatedGraphQLMetrics( + _ context.Context, + req *connect.Request[graphqlmetricsv1.PublishAggregatedGraphQLRequestMetricsRequest], +) (*connect.Response[graphqlmetricsv1.PublishAggregatedGraphQLRequestMetricsResponse], error) { + h.mu.Lock() + h.auth = append(h.auth, req.Header().Get("Authorization")) + h.aggregate = append(h.aggregate, req.Msg) + h.mu.Unlock() + return connect.NewResponse(&graphqlmetricsv1.PublishAggregatedGraphQLRequestMetricsResponse{}), nil +} + +func newRecordingServer(t *testing.T) (*recordingHandler, string) { + t.Helper() + handler := &recordingHandler{} + mux := http.NewServeMux() + path, h := graphqlmetricsv1connect.NewGraphQLMetricsServiceHandler(handler) + mux.Handle(path, h) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return handler, srv.URL +} + +// TestSink_Export_DoesNotSetAuthHeader confirms the sink no longer manages +// the Authorization header. Auth is now applied by exporter.WithBearerAuth at +// the Connect-client layer; a client constructed without it must yield an +// empty Authorization on the wire even when the sink runs Export. +func TestSink_Export_DoesNotSetAuthHeader(t *testing.T) { + t.Parallel() + handler, url := newRecordingServer(t) + + client := graphqlmetricsv1connect.NewGraphQLMetricsServiceClient(http.DefaultClient, url) + sink := NewGraphQLMetricsSink(GraphQLMetricsSinkConfig{Client: client, Logger: zap.NewNop()}) + + batch := []*graphqlmetricsv1.SchemaUsageInfo{ + { + OperationInfo: &graphqlmetricsv1.OperationInfo{Hash: "h", Name: "Q", Type: graphqlmetricsv1.OperationType_QUERY}, + ClientInfo: &graphqlmetricsv1.ClientInfo{Name: "c", Version: "v"}, + }, + } + require.NoError(t, sink.Export(context.Background(), batch)) + + handler.mu.Lock() + defer handler.mu.Unlock() + require.Equal(t, []string{""}, handler.auth) +} + +func TestSink_Export_AggregatesBeforeSending(t *testing.T) { + t.Parallel() + handler, url := newRecordingServer(t) + + client := graphqlmetricsv1connect.NewGraphQLMetricsServiceClient(http.DefaultClient, url) + sink := NewGraphQLMetricsSink(GraphQLMetricsSinkConfig{Client: client, Logger: zap.NewNop()}) + + // Two identical SchemaUsageInfo items must aggregate down to one entry + // with RequestCount == 2 (the whole point of AggregateSchemaUsageInfoBatch). + usage := func() *graphqlmetricsv1.SchemaUsageInfo { + return &graphqlmetricsv1.SchemaUsageInfo{ + OperationInfo: &graphqlmetricsv1.OperationInfo{Hash: "same", Name: "Q", Type: graphqlmetricsv1.OperationType_QUERY}, + ClientInfo: &graphqlmetricsv1.ClientInfo{Name: "c", Version: "v"}, + SchemaInfo: &graphqlmetricsv1.SchemaInfo{Version: "1"}, + RequestInfo: &graphqlmetricsv1.RequestInfo{StatusCode: 200}, + } + } + require.NoError(t, sink.Export(context.Background(), []*graphqlmetricsv1.SchemaUsageInfo{usage(), usage()})) + + handler.mu.Lock() + defer handler.mu.Unlock() + require.Len(t, handler.aggregate, 1) + require.Len(t, handler.aggregate[0].Aggregation, 1) + require.Equal(t, uint64(2), handler.aggregate[0].Aggregation[0].RequestCount) +} + +func TestSink_Export_EmptyBatchIsNoOp(t *testing.T) { + t.Parallel() + handler, url := newRecordingServer(t) + + client := graphqlmetricsv1connect.NewGraphQLMetricsServiceClient(http.DefaultClient, url) + sink := NewGraphQLMetricsSink(GraphQLMetricsSinkConfig{Client: client, Logger: zap.NewNop()}) + + require.NoError(t, sink.Export(context.Background(), nil)) + require.NoError(t, sink.Export(context.Background(), []*graphqlmetricsv1.SchemaUsageInfo{})) + + handler.mu.Lock() + defer handler.mu.Unlock() + require.Empty(t, handler.aggregate, "empty batches must not hit the wire") +} diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index 6f547a2171..4b854d67d7 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -1051,6 +1051,19 @@ type EntityCachingConfiguration struct { L1 EntityCachingL1Configuration `yaml:"l1"` L2 EntityCachingL2Configuration `yaml:"l2"` SubgraphCacheOverrides []EntityCachingSubgraphCacheOverride `yaml:"subgraph_cache_overrides,omitempty"` + EventsExport EntityCacheEventsExportConfig `yaml:"events_export,omitempty"` +} + +// EntityCacheEventsExportConfig configures the per-fetch cache event export +// pipeline. When enabled, the router buffers raw cache events in memory and +// ships them to the cosmo cache-events Connect endpoint (typically the same +// binary that receives graphql_metrics). +type EntityCacheEventsExportConfig struct { + Enabled bool `yaml:"enabled" envDefault:"false" env:"ENTITY_CACHING_EVENTS_EXPORT_ENABLED"` + Endpoint string `yaml:"endpoint,omitempty" env:"ENTITY_CACHING_EVENTS_EXPORT_ENDPOINT"` + BatchSize int `yaml:"batch_size,omitempty" envDefault:"1024" env:"ENTITY_CACHING_EVENTS_EXPORT_BATCH_SIZE"` + QueueSize int `yaml:"queue_size,omitempty" envDefault:"16384" env:"ENTITY_CACHING_EVENTS_EXPORT_QUEUE_SIZE"` + Interval time.Duration `yaml:"interval,omitempty" envDefault:"5s" env:"ENTITY_CACHING_EVENTS_EXPORT_INTERVAL"` } type EntityCachingL1Configuration struct { diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index 6b2f8b9d97..023c263d2c 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -461,6 +461,43 @@ } } } + }, + "events_export": { + "type": "object", + "additionalProperties": false, + "description": "Streams raw per-fetch entity-cache decision events to a remote endpoint (typically the cosmo graphqlmetrics service) for ClickHouse ingestion.", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable/disable per-fetch cache event export.", + "default": false + }, + "endpoint": { + "type": "string", + "description": "Connect/gRPC endpoint of the cache events service. Defaults to the graphql_metrics collector endpoint when empty.", + "format": "uri" + }, + "batch_size": { + "type": "integer", + "description": "Maximum number of events flushed in a single batch.", + "default": 1024, + "minimum": 1 + }, + "queue_size": { + "type": "integer", + "description": "In-memory queue capacity. Events beyond this are dropped if the consumer cannot keep up.", + "default": 16384, + "minimum": 1 + }, + "interval": { + "type": "string", + "description": "Maximum time to wait before flushing a partial batch. Specified as a duration string (e.g., '5s', '1m').", + "default": "5s", + "duration": { + "minimum": "1s" + } + } + } } } }, diff --git a/router/pkg/config/testdata/config_defaults.json b/router/pkg/config/testdata/config_defaults.json index 8f39bfd1bc..e6a5c6145f 100644 --- a/router/pkg/config/testdata/config_defaults.json +++ b/router/pkg/config/testdata/config_defaults.json @@ -554,7 +554,14 @@ "CooldownPeriod": 10000000000 } }, - "SubgraphCacheOverrides": null + "SubgraphCacheOverrides": null, + "EventsExport": { + "Enabled": false, + "Endpoint": "", + "BatchSize": 1024, + "QueueSize": 16384, + "Interval": 5000000000 + } }, "ExecutionConfig": { "File": { diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json index 259ae81d3b..2e93674fa7 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -999,7 +999,14 @@ "CooldownPeriod": 10000000000 } }, - "SubgraphCacheOverrides": null + "SubgraphCacheOverrides": null, + "EventsExport": { + "Enabled": false, + "Endpoint": "", + "BatchSize": 1024, + "QueueSize": 16384, + "Interval": 5000000000 + } }, "ExecutionConfig": { "File": {