diff --git a/configs/gateway.config.ts b/configs/gateway.config.ts new file mode 100644 index 0000000000..71b220b4c0 --- /dev/null +++ b/configs/gateway.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from '@graphql-hive/gateway'; +import { hiveTracingSetup } from '@graphql-hive/plugin-opentelemetry/setup'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; // install + +hiveTracingSetup({ + contextManager: new AsyncLocalStorageContextManager(), + target: process.env.HIVE_TRACING_TARGET!, + accessToken: process.env.HIVE_TRACING_ACCESS_TOKEN!, + // optional, for self-hosting + endpoint: process.env.HIVE_TRACING_ENDPOINT!, +}); + +export const gatewayConfig = defineConfig({ + openTelemetry: { + traces: true, + }, +}); diff --git a/deployment/index.ts b/deployment/index.ts index 3e2ce66491..d487752fb6 100644 --- a/deployment/index.ts +++ b/deployment/index.ts @@ -14,6 +14,7 @@ import { configureGithubApp } from './services/github'; import { deployGraphQL } from './services/graphql'; import { deployKafka } from './services/kafka'; import { deployObservability } from './services/observability'; +import { deployOTELCollector } from './services/otel-collector'; import { deploySchemaPolicy } from './services/policy'; import { deployPostgres } from './services/postgres'; import { deployProxy } from './services/proxy'; @@ -278,6 +279,15 @@ if (hiveAppPersistedDocumentsAbsolutePath && RUN_PUBLISH_COMMANDS) { }); } +const otelCollector = deployOTELCollector({ + environment, + graphql, + dbMigrations, + clickhouse, + image: docker.factory.getImageId('otel-collector', imagesTag), + docker, +}); + const app = deployApp({ environment, graphql, @@ -306,6 +316,7 @@ const proxy = deployProxy({ usage, environment, publicGraphQLAPIGateway, + otelCollector, }); deployCloudFlareSecurityTransform({ @@ -332,4 +343,5 @@ export const schemaApiServiceId = schema.service.id; export const webhooksApiServiceId = webhooks.service.id; export const appId = app.deployment.id; +export const otelCollectorId = otelCollector.deployment.id; export const publicIp = proxy.get()!.status.loadBalancer.ingress[0].ip; diff --git a/deployment/services/environment.ts b/deployment/services/environment.ts index 6f0982fbd7..e761f87987 100644 --- a/deployment/services/environment.ts +++ b/deployment/services/environment.ts @@ -79,6 +79,11 @@ export function prepareEnvironment(input: { cpuLimit: isProduction ? '512m' : '150m', memoryLimit: isProduction ? '1000Mi' : '300Mi', }, + tracingCollector: { + cpuLimit: isProduction ? '1000m' : '100m', + memoryLimit: isProduction ? '2000Mi' : '200Mi', + maxReplicas: isProduction || isStaging ? 3 : 1, + }, }, }; } diff --git a/deployment/services/otel-collector.ts b/deployment/services/otel-collector.ts new file mode 100644 index 0000000000..581907c281 --- /dev/null +++ b/deployment/services/otel-collector.ts @@ -0,0 +1,60 @@ +import { serviceLocalEndpoint } from '../utils/local-endpoint'; +import { ServiceDeployment } from '../utils/service-deployment'; +import { Clickhouse } from './clickhouse'; +import { DbMigrations } from './db-migrations'; +import { Docker } from './docker'; +import { Environment } from './environment'; +import { GraphQL } from './graphql'; + +export type OTELCollector = ReturnType; + +export function deployOTELCollector(args: { + image: string; + environment: Environment; + docker: Docker; + clickhouse: Clickhouse; + dbMigrations: DbMigrations; + graphql: GraphQL; +}) { + return new ServiceDeployment( + 'otel-collector', + { + image: args.image, + imagePullSecret: args.docker.secret, + env: { + ...args.environment.envVars, + HIVE_OTEL_AUTH_ENDPOINT: serviceLocalEndpoint(args.graphql.service).apply( + value => value + '/otel-auth', + ), + }, + /** + * We are using the healthcheck extension. + * https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/extension/healthcheckextension + */ + probePort: 13133, + readinessProbe: '/', + livenessProbe: '/', + startupProbe: '/', + exposesMetrics: true, + replicas: args.environment.podsConfig.tracingCollector.maxReplicas, + pdb: true, + availabilityOnEveryNode: true, + port: 4318, + memoryLimit: args.environment.podsConfig.tracingCollector.memoryLimit, + autoScaling: { + maxReplicas: args.environment.podsConfig.tracingCollector.maxReplicas, + cpu: { + limit: args.environment.podsConfig.tracingCollector.cpuLimit, + cpuAverageToScale: 80, + }, + }, + }, + [args.clickhouse.deployment, args.clickhouse.service, args.dbMigrations], + ) + .withSecret('CLICKHOUSE_HOST', args.clickhouse.secret, 'host') + .withSecret('CLICKHOUSE_PORT', args.clickhouse.secret, 'port') + .withSecret('CLICKHOUSE_USERNAME', args.clickhouse.secret, 'username') + .withSecret('CLICKHOUSE_PASSWORD', args.clickhouse.secret, 'password') + .withSecret('CLICKHOUSE_PROTOCOL', args.clickhouse.secret, 'protocol') + .deploy(); +} diff --git a/deployment/services/proxy.ts b/deployment/services/proxy.ts index dba4c131a7..07037456b7 100644 --- a/deployment/services/proxy.ts +++ b/deployment/services/proxy.ts @@ -5,6 +5,7 @@ import { App } from './app'; import { Environment } from './environment'; import { GraphQL } from './graphql'; import { Observability } from './observability'; +import { OTELCollector } from './otel-collector'; import { type PublicGraphQLAPIGateway } from './public-graphql-api-gateway'; import { Usage } from './usage'; @@ -15,6 +16,7 @@ export function deployProxy({ environment, observability, publicGraphQLAPIGateway, + otelCollector, }: { observability: Observability; environment: Environment; @@ -22,6 +24,7 @@ export function deployProxy({ app: App; usage: Usage; publicGraphQLAPIGateway: PublicGraphQLAPIGateway; + otelCollector: OTELCollector; }) { const { tlsIssueName } = new CertManager().deployCertManagerAndIssuer(); const commonConfig = new pulumi.Config('common'); @@ -113,5 +116,13 @@ export function deployProxy({ requestTimeout: '60s', retriable: true, }, + { + name: 'otel-traces', + path: '/otel/v1/traces', + customRewrite: '/v1/traces', + service: otelCollector.service, + requestTimeout: '60s', + retriable: true, + }, ]); } diff --git a/deployment/utils/service-deployment.ts b/deployment/utils/service-deployment.ts index 9bc9aa8f89..53f4197810 100644 --- a/deployment/utils/service-deployment.ts +++ b/deployment/utils/service-deployment.ts @@ -40,6 +40,8 @@ export class ServiceDeployment { args?: kx.types.Container['args']; image: string; port?: number; + /** Port to use for liveness, startup and readiness probes. */ + probePort?: number; serviceAccountName?: pulumi.Output; livenessProbe?: string | ProbeConfig; readinessProbe?: string | ProbeConfig; @@ -107,6 +109,7 @@ export class ServiceDeployment { createPod(asJob: boolean) { const port = this.options.port || 3000; + const probePort = this.options.probePort ?? port; const additionalEnv: any[] = normalizeEnv(this.options.env); const secretsEnv: any[] = normalizeEnvSecrets(this.envSecrets); @@ -125,14 +128,14 @@ export class ServiceDeployment { timeoutSeconds: 5, httpGet: { path: this.options.livenessProbe, - port, + port: probePort, }, } : { ...this.options.livenessProbe, httpGet: { path: this.options.livenessProbe.endpoint, - port, + port: probePort, }, }; } @@ -147,14 +150,14 @@ export class ServiceDeployment { timeoutSeconds: 5, httpGet: { path: this.options.readinessProbe, - port, + port: probePort, }, } : { ...this.options.readinessProbe, httpGet: { path: this.options.readinessProbe.endpoint, - port, + port: probePort, }, }; } @@ -169,14 +172,14 @@ export class ServiceDeployment { timeoutSeconds: 10, httpGet: { path: this.options.startupProbe, - port, + port: probePort, }, } : { ...this.options.startupProbe, httpGet: { path: this.options.startupProbe.endpoint, - port, + port: probePort, }, }; } diff --git a/docker/configs/otel-collector/builder-config.yaml b/docker/configs/otel-collector/builder-config.yaml new file mode 100644 index 0000000000..79db56bb3b --- /dev/null +++ b/docker/configs/otel-collector/builder-config.yaml @@ -0,0 +1,33 @@ +dist: + version: 0.122.0 + name: otelcol-custom + description: Custom OTel Collector distribution + output_path: ./otelcol-custom + +receivers: + - gomod: go.opentelemetry.io/collector/receiver/otlpreceiver v0.122.0 + +processors: + - gomod: go.opentelemetry.io/collector/processor/batchprocessor v0.122.0 + - gomod: go.opentelemetry.io/collector/processor/memorylimiterprocessor v0.122.0 + - gomod: + github.com/open-telemetry/opentelemetry-collector-contrib/processor/attributesprocessor + v0.122.0 + - gomod: + github.com/open-telemetry/opentelemetry-collector-contrib/processor/filterprocessor v0.122.0 + +exporters: + - gomod: go.opentelemetry.io/collector/exporter/debugexporter v0.122.0 + - gomod: + github.com/open-telemetry/opentelemetry-collector-contrib/exporter/clickhouseexporter v0.122.0 + +extensions: + - gomod: + github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckextension + v0.122.0 + - gomod: + github.com/graphql-hive/opentelemetry-collector-contrib/extension/hiveauthextension cd0c57cf22 + +replaces: + - github.com/graphql-hive/opentelemetry-collector-contrib/extension/hiveauthextension => + /build/extension-hiveauth diff --git a/docker/configs/otel-collector/config.yaml b/docker/configs/otel-collector/config.yaml new file mode 100644 index 0000000000..b36e94d0e4 --- /dev/null +++ b/docker/configs/otel-collector/config.yaml @@ -0,0 +1,79 @@ +extensions: + hiveauth: + endpoint: ${HIVE_OTEL_AUTH_ENDPOINT} + health_check: + endpoint: '0.0.0.0:13133' +receivers: + otlp: + protocols: + grpc: + include_metadata: true + endpoint: '0.0.0.0:4317' + auth: + authenticator: hiveauth + http: + cors: + allowed_origins: ['*'] + allowed_headers: ['*'] + include_metadata: true + endpoint: '0.0.0.0:4318' + auth: + authenticator: hiveauth +processors: + batch: + timeout: 5s + send_batch_size: 10000 + attributes: + actions: + - key: hive.target_id + from_context: auth.targetId + action: insert + memory_limiter: + check_interval: 1s + limit_percentage: 80 + spike_limit_percentage: 20 +exporters: + debug: + verbosity: detailed + sampling_initial: 5 + sampling_thereafter: 200 + clickhouse: + endpoint: ${CLICKHOUSE_PROTOCOL}://${CLICKHOUSE_HOST}:${CLICKHOUSE_PORT}?dial_timeout=10s&compress=lz4&async_insert=1 + database: default + async_insert: true + username: ${CLICKHOUSE_USERNAME} + password: ${CLICKHOUSE_PASSWORD} + create_schema: false + ttl: 720h + compress: lz4 + logs_table_name: otel_logs + traces_table_name: otel_traces + metrics_table_name: otel_metrics + timeout: 5s + retry_on_failure: + enabled: true + initial_interval: 5s + max_interval: 30s + max_elapsed_time: 300s +service: + extensions: + - hiveauth + - health_check + telemetry: + logs: + level: INFO + encoding: json + output_paths: ['stdout'] + error_output_paths: ['stderr'] + metrics: + address: '0.0.0.0:10254' + pipelines: + traces: + receivers: [otlp] + processors: + - memory_limiter + - attributes + - batch + exporters: + - clickhouse + # - debug diff --git a/docker/configs/otel-collector/extension-hiveauth/config.go b/docker/configs/otel-collector/extension-hiveauth/config.go new file mode 100644 index 0000000000..9059ce00eb --- /dev/null +++ b/docker/configs/otel-collector/extension-hiveauth/config.go @@ -0,0 +1,28 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package hiveauthextension // import "github.com/graphql-hive/opentelemetry-collector-contrib/extension/hiveauthextension" + +import ( + "errors" + "time" +) + +type Config struct { + // Endpoint is the address of the authentication server + Endpoint string `mapstructure:"endpoint"` + // Timeout is the timeout for the HTTP request to the auth service + Timeout time.Duration `mapstructure:"timeout"` +} + +func (cfg *Config) Validate() error { + if cfg.Endpoint == "" { + return errors.New("missing endpoint") + } + + if cfg.Timeout <= 0 { + return errors.New("timeout must be a positive value") + } + + return nil +} diff --git a/docker/configs/otel-collector/extension-hiveauth/doc.go b/docker/configs/otel-collector/extension-hiveauth/doc.go new file mode 100644 index 0000000000..d011f52b47 --- /dev/null +++ b/docker/configs/otel-collector/extension-hiveauth/doc.go @@ -0,0 +1,7 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +//go:generate mdatagen metadata.yaml + +// Package hiveauthextension accepts HTTP requests and forwards them to an external authentication service. +package hiveauthextension // import "github.com/graphql-hive/opentelemetry-collector-contrib/extension/hiveauthextension" diff --git a/docker/configs/otel-collector/extension-hiveauth/extension.go b/docker/configs/otel-collector/extension-hiveauth/extension.go new file mode 100644 index 0000000000..353a906cdd --- /dev/null +++ b/docker/configs/otel-collector/extension-hiveauth/extension.go @@ -0,0 +1,257 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package hiveauthextension + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/patrickmn/go-cache" + "go.opentelemetry.io/collector/client" + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/extension/extensionauth" + "go.uber.org/zap" + "golang.org/x/sync/singleflight" +) + +var _ extensionauth.Server = (*hiveAuthExtension)(nil) + +var _ client.AuthData = (*authData)(nil) + +type authData struct { + targetId string +} + +func (a *authData) GetAttribute(name string) any { + switch name { + case "targetId": + return a.targetId + default: + return nil + } +} + +func (*authData) GetAttributeNames() []string { + return []string{"targetId"} +} + +type hiveAuthExtension struct { + logger *zap.Logger + config *Config + client *http.Client + group singleflight.Group + cache *cache.Cache +} + +func (h *hiveAuthExtension) Start(_ context.Context, _ component.Host) error { + h.logger.Info("Starting hive auth extension", zap.String("endpoint", h.config.Endpoint), zap.Duration("timeout", h.config.Timeout)) + return nil +} + +func (h *hiveAuthExtension) Shutdown(_ context.Context) error { + h.logger.Info("Shutting down hive auth extension") + return nil +} + +type AuthStatusError struct { + Code int + Msg string +} + +func (e *AuthStatusError) Error() string { + return fmt.Sprintf("authentication failed: status %d, %s", e.Code, e.Msg) +} + +func getHeader(h map[string][]string, headerKey string, metadataKey string) string { + headerValues, ok := h[headerKey] + + if !ok { + headerValues, ok = h[metadataKey] + } + + if !ok { + for k, v := range h { + if strings.EqualFold(k, metadataKey) { + headerValues = v + break + } + } + } + + if len(headerValues) == 0 { + return "" + } + + return headerValues[0] +} + +func getAuthHeader(h map[string][]string) string { + const ( + canonicalHeaderKey = "Authorization" + metadataKey = "authorization" + ) + + return getHeader(h, canonicalHeaderKey, metadataKey) +} + +func getTargetRefHeader(h map[string][]string) string { + const ( + canonicalHeaderKey = "X-Hive-Target-Ref" + metadataKey = "x-hive-target-ref" + ) + + return getHeader(h, canonicalHeaderKey, metadataKey) +} + +type authResult struct { + err error + targetId string +} + +func (h *hiveAuthExtension) doAuthRequest(ctx context.Context, auth string, targetRef string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.config.Endpoint, nil) + if err != nil { + h.logger.Error("failed to create auth request", zap.Error(err)) + return "", err + } + req.Header.Set("Authorization", auth) + req.Header.Set("X-Hive-Target-Ref", targetRef) + + // Retry parameters. + const maxRetries = 3 + const retryDelay = 100 * time.Millisecond + var lastStatus int + + for attempt := 0; attempt < maxRetries; attempt++ { + resp, err := h.client.Do(req) + if err != nil { + h.logger.Error("error calling authentication service", zap.Error(err)) + return "", err + } + lastStatus = resp.StatusCode + + // Success. + if resp.StatusCode == http.StatusOK { + var result struct { + TargetId string `json:"targetId"` + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return "", err + } + if err := json.Unmarshal(body, &result); err != nil { + return "", err + } + h.logger.Debug("authentication succeeded", zap.String("targetId", result.TargetId)) + return result.TargetId, nil + } + + // For 5XX responses, retry. + if resp.StatusCode >= 500 && resp.StatusCode < 600 { + h.logger.Warn("received 5xx response, retrying", + zap.Int("attempt", attempt+1), + zap.String("status", resp.Status)) + resp.Body.Close() + + select { + case <-time.After(retryDelay * time.Duration(attempt + 1)): + // Continue to next attempt. + case <-ctx.Done(): + return "", ctx.Err() + } + continue + } + + // For non-retryable errors. + errMsg := fmt.Sprintf("authentication failed: received status %s", resp.Status) + h.logger.Warn(errMsg) + resp.Body.Close() + return "", &AuthStatusError{ + Code: resp.StatusCode, + Msg: "non-retryable error", + } + } + + return "", &AuthStatusError{ + Code: lastStatus, + Msg: "authentication failed after retries", + } +} + +func (h *hiveAuthExtension) Authenticate(ctx context.Context, headers map[string][]string) (context.Context, error) { + auth := getAuthHeader(headers) + targetRef := getTargetRefHeader(headers) + if auth == "" { + return ctx, errors.New("No auth provided") + } + + if targetRef == "" { + return ctx, errors.New("No target ref provided") + } + + cacheKey := fmt.Sprintf("%s|%s", auth, targetRef) + + if cached, found := h.cache.Get(cacheKey); found { + res := cached.(authResult) + + if res.err == nil { + cl := client.FromContext(ctx) + cl.Auth = &authData{targetId: res.targetId} + return client.NewContext(ctx, cl), nil + } + + return ctx, res.err + } + + // Deduplicate concurrent calls. + targetId, err, _ := h.group.Do(cacheKey, func() (any, error) { + return h.doAuthRequest(ctx, auth, targetRef) + }) + + var ttl time.Duration + if err == nil { + ttl = 30 * time.Second + } else { + ttl = 10 * time.Second + } + h.cache.Set(cacheKey, authResult{err: err, targetId: targetId.(string)}, ttl) + + if err == nil { + cl := client.FromContext(ctx) + cl.Auth = &authData{targetId: targetId.(string)} + return client.NewContext(ctx, cl), nil + } + + return ctx, err +} + +func newHiveAuthExtension( + logger *zap.Logger, + cfg component.Config, +) (extensionauth.Server, error) { + c, ok := cfg.(*Config) + if !ok { + return nil, errors.New("invalid configuration") + } + + if err := c.Validate(); err != nil { + return nil, err + } + + return &hiveAuthExtension{ + logger: logger, + config: c, + client: &http.Client{ + Timeout: c.Timeout, + }, + cache: cache.New(30*time.Second, time.Minute), + }, nil +} diff --git a/docker/configs/otel-collector/extension-hiveauth/factory.go b/docker/configs/otel-collector/extension-hiveauth/factory.go new file mode 100644 index 0000000000..bf83883f54 --- /dev/null +++ b/docker/configs/otel-collector/extension-hiveauth/factory.go @@ -0,0 +1,35 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package hiveauthextension // import "github.com/graphql-hive/opentelemetry-collector-contrib/extension/hiveauthextension" + +import ( + "context" + "time" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/extension" + + "github.com/graphql-hive/opentelemetry-collector-contrib/extension/hiveauthextension/internal/metadata" +) + +// NewFactory creates a factory for the static bearer token Authenticator extension. +func NewFactory() extension.Factory { + return extension.NewFactory( + metadata.Type, + createDefaultConfig, + createExtension, + metadata.ExtensionStability, + ) +} + +func createDefaultConfig() component.Config { + return &Config{ + Endpoint: "http://localhost:3000/", + Timeout: 5 * time.Second, + } +} + +func createExtension(_ context.Context, params extension.Settings, cfg component.Config) (extension.Extension, error) { + return newHiveAuthExtension(params.Logger, cfg.(*Config)) +} diff --git a/docker/configs/otel-collector/extension-hiveauth/generated_component_test.go b/docker/configs/otel-collector/extension-hiveauth/generated_component_test.go new file mode 100644 index 0000000000..fe32b33d7f --- /dev/null +++ b/docker/configs/otel-collector/extension-hiveauth/generated_component_test.go @@ -0,0 +1,52 @@ +// Code generated by mdatagen. DO NOT EDIT. + +package hiveauthextension + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/component/componenttest" + "go.opentelemetry.io/collector/confmap/confmaptest" + "go.opentelemetry.io/collector/extension/extensiontest" +) + +var typ = component.MustNewType("hiveauth") + +func TestComponentFactoryType(t *testing.T) { + require.Equal(t, typ, NewFactory().Type()) +} + +func TestComponentConfigStruct(t *testing.T) { + require.NoError(t, componenttest.CheckConfigStruct(NewFactory().CreateDefaultConfig())) +} + +func TestComponentLifecycle(t *testing.T) { + factory := NewFactory() + + cm, err := confmaptest.LoadConf("metadata.yaml") + require.NoError(t, err) + cfg := factory.CreateDefaultConfig() + sub, err := cm.Sub("tests::config") + require.NoError(t, err) + require.NoError(t, sub.Unmarshal(&cfg)) + t.Run("shutdown", func(t *testing.T) { + e, err := factory.Create(context.Background(), extensiontest.NewNopSettings(typ), cfg) + require.NoError(t, err) + err = e.Shutdown(context.Background()) + require.NoError(t, err) + }) + t.Run("lifecycle", func(t *testing.T) { + firstExt, err := factory.Create(context.Background(), extensiontest.NewNopSettings(typ), cfg) + require.NoError(t, err) + require.NoError(t, firstExt.Start(context.Background(), componenttest.NewNopHost())) + require.NoError(t, firstExt.Shutdown(context.Background())) + + secondExt, err := factory.Create(context.Background(), extensiontest.NewNopSettings(typ), cfg) + require.NoError(t, err) + require.NoError(t, secondExt.Start(context.Background(), componenttest.NewNopHost())) + require.NoError(t, secondExt.Shutdown(context.Background())) + }) +} diff --git a/docker/configs/otel-collector/extension-hiveauth/generated_package_test.go b/docker/configs/otel-collector/extension-hiveauth/generated_package_test.go new file mode 100644 index 0000000000..1b6c6798bd --- /dev/null +++ b/docker/configs/otel-collector/extension-hiveauth/generated_package_test.go @@ -0,0 +1,13 @@ +// Code generated by mdatagen. DO NOT EDIT. + +package hiveauthextension + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/docker/configs/otel-collector/extension-hiveauth/go.mod b/docker/configs/otel-collector/extension-hiveauth/go.mod new file mode 100644 index 0000000000..081191d83a --- /dev/null +++ b/docker/configs/otel-collector/extension-hiveauth/go.mod @@ -0,0 +1,52 @@ +module github.com/graphql-hive/console/go/open-telemetry-collector-extension-hiveauth + +go 1.23.0 + +require ( + github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/stretchr/testify v1.10.0 + go.opentelemetry.io/collector/client v1.28.0 + go.opentelemetry.io/collector/component v1.28.0 + go.opentelemetry.io/collector/component/componenttest v0.122.0 + go.opentelemetry.io/collector/confmap v1.28.0 + go.opentelemetry.io/collector/extension v1.28.0 + go.opentelemetry.io/collector/extension/extensionauth v0.122.0 + go.opentelemetry.io/collector/extension/extensiontest v0.122.0 + go.uber.org/goleak v1.3.0 + go.uber.org/zap v1.27.0 + golang.org/x/sync v0.12.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/go-version v1.7.0 // indirect + github.com/knadh/koanf/maps v0.1.1 // indirect + github.com/knadh/koanf/providers/confmap v0.1.0 // indirect + github.com/knadh/koanf/v2 v2.1.2 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/collector/featuregate v1.28.0 // indirect + go.opentelemetry.io/collector/pdata v1.28.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/sdk v1.35.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/net v0.37.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect + google.golang.org/grpc v1.71.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/open-telemetry/opentelemetry-collector-contrib/internal/common => ../../internal/common diff --git a/docker/configs/otel-collector/extension-hiveauth/go.sum b/docker/configs/otel-collector/extension-hiveauth/go.sum new file mode 100644 index 0000000000..50760323df --- /dev/null +++ b/docker/configs/otel-collector/extension-hiveauth/go.sum @@ -0,0 +1,133 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= +github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/providers/confmap v0.1.0 h1:gOkxhHkemwG4LezxxN8DMOFopOPghxRVp7JbIvdvqzU= +github.com/knadh/koanf/providers/confmap v0.1.0/go.mod h1:2uLhxQzJnyHKfxG927awZC7+fyHFdQkd697K4MdLnIU= +github.com/knadh/koanf/v2 v2.1.2 h1:I2rtLRqXRy1p01m/utEtpZSSA6dcJbgGVuE27kW2PzQ= +github.com/knadh/koanf/v2 v2.1.2/go.mod h1:Gphfaen0q1Fc1HTgJgSTC4oRX9R2R5ErYMZJy8fLJBo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/collector/client v1.28.0 h1:QKewiYc5Fc87pViqt8Lav/lQAybMUO4hf1ubV0gqRtg= +go.opentelemetry.io/collector/client v1.28.0/go.mod h1:mopBD0EZwShVUMjet1ElpzIvOlmVxpJ1r7b7XSr9npg= +go.opentelemetry.io/collector/component v1.28.0 h1:SQAGxxuyZ+d5tOsuEka8m9oE+wAroaYQpJ8NTIbl6Lk= +go.opentelemetry.io/collector/component v1.28.0/go.mod h1:te8gbcKU6Mgu7ewo/2VYDSbCkLrhOYYy2llayXCF0bI= +go.opentelemetry.io/collector/component/componenttest v0.122.0 h1:TxMm4nXB9iByQhDP0QFZwYxG+BFXEB6qUUwVh5YYW7g= +go.opentelemetry.io/collector/component/componenttest v0.122.0/go.mod h1:zzRftQeGgVPxKzXkJEx3ghC4U3hgiDRuuNljsq3cLPI= +go.opentelemetry.io/collector/confmap v1.28.0 h1:pUQh4eOW0YQ1GFWTDP5pw/ZMQuppkz6oSoDDloAH/Sc= +go.opentelemetry.io/collector/confmap v1.28.0/go.mod h1:k/3fo+2RE6m+OKlJzx78Q8hstABYwYgvXO3u9zyTeHI= +go.opentelemetry.io/collector/consumer v1.28.0 h1:3JzDm7EFAF9ws4O3vVou5n8egdGZtrRN3xVw6AjNtqE= +go.opentelemetry.io/collector/consumer v1.28.0/go.mod h1:Ge1HGm5aRYTW+SXggM+US9phb/BQR2of4FZ8r/3OH3Y= +go.opentelemetry.io/collector/extension v1.28.0 h1:E3j6/EtcahF2bX9DvRduLQ6tD7SuZdXM9DzAi7NSAeY= +go.opentelemetry.io/collector/extension v1.28.0/go.mod h1:3MW9IGCNNgjG/ngkALVH5epwbCwYuoZMTbh4523aYv0= +go.opentelemetry.io/collector/extension/extensionauth v0.122.0 h1:ypFO+JFrUsxWb2llD50Thyikrigzvac0cJeNh8nRT8M= +go.opentelemetry.io/collector/extension/extensionauth v0.122.0/go.mod h1:EsGPuDcbOxwku0ebMmtVOm+j8FCdH6yd7lQ6JT/1TXc= +go.opentelemetry.io/collector/extension/extensiontest v0.122.0 h1:daeCPXhb4HveyeYyX6G0IqjGuvWJprZeiq7pZiSdC+M= +go.opentelemetry.io/collector/extension/extensiontest v0.122.0/go.mod h1:JXSONLbyuX+uOy1gcQ3Jcp/48pfkh0RiZPy7XkyCBdU= +go.opentelemetry.io/collector/featuregate v1.28.0 h1:nkaMw0HyOSxojLwlezF2O/xJ9T/Jo1a0iEetesT9lr0= +go.opentelemetry.io/collector/featuregate v1.28.0/go.mod h1:Y/KsHbvREENKvvN9RlpiWk/IGBK+CATBYzIIpU7nccc= +go.opentelemetry.io/collector/pdata v1.28.0 h1:xSZyvTOOc2Wmz4PoxrVqeQfodLgs9k7gowLAnzZN0eU= +go.opentelemetry.io/collector/pdata v1.28.0/go.mod h1:asKE8MD/4SOKz1mCrGdAz4VO2U2HUNg8A6094uK7pq0= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/docker/configs/otel-collector/extension-hiveauth/internal/metadata/generated_status.go b/docker/configs/otel-collector/extension-hiveauth/internal/metadata/generated_status.go new file mode 100644 index 0000000000..19b3cfac8b --- /dev/null +++ b/docker/configs/otel-collector/extension-hiveauth/internal/metadata/generated_status.go @@ -0,0 +1,16 @@ +// Code generated by mdatagen. DO NOT EDIT. + +package metadata + +import ( + "go.opentelemetry.io/collector/component" +) + +var ( + Type = component.MustNewType("hiveauth") + ScopeName = "github.com/graphql-hive/opentelemetry-collector-contrib/extension/hiveauthextension" +) + +const ( + ExtensionStability = component.StabilityLevelBeta +) diff --git a/docker/configs/otel-collector/extension-hiveauth/metadata.yaml b/docker/configs/otel-collector/extension-hiveauth/metadata.yaml new file mode 100644 index 0000000000..623738a01e --- /dev/null +++ b/docker/configs/otel-collector/extension-hiveauth/metadata.yaml @@ -0,0 +1,12 @@ +type: hiveauth + +status: + class: extension + stability: + beta: [extension] + distributions: [contrib, k8s] + codeowners: + active: [kamilkisiela] + +tests: + config: diff --git a/docker/docker-compose.community.yml b/docker/docker-compose.community.yml index 5545083aed..6b45a16f95 100644 --- a/docker/docker-compose.community.yml +++ b/docker/docker-compose.community.yml @@ -422,3 +422,21 @@ services: LOG_LEVEL: '${LOG_LEVEL:-debug}' SENTRY: '${SENTRY:-0}' SENTRY_DSN: '${SENTRY_DSN:-}' + + otel-collector: + depends_on: + clickhouse: + condition: service_healthy + image: '${DOCKER_REGISTRY}otel-collector${DOCKER_TAG}' + environment: + HIVE_OTEL_AUTH_ENDPOINT: 'http://server:3001/otel-auth' + CLICKHOUSE_PROTOCOL: 'http' + CLICKHOUSE_HOST: clickhouse + CLICKHOUSE_PORT: '8123' + CLICKHOUSE_USERNAME: '${CLICKHOUSE_USER}' + CLICKHOUSE_PASSWORD: '${CLICKHOUSE_PASSWORD}' + ports: + - '4317:4317' + - '4318:4318' + networks: + - 'stack' diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index a76deecc29..7dfbad365f 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -16,6 +16,7 @@ services: POSTGRES_PASSWORD: postgres POSTGRES_DB: registry PGDATA: /var/lib/postgresql/data + HIVE_OTEL_AUTH_ENDPOINT: 'http://host.docker.internal:3001/otel-auth' volumes: - ./.hive-dev/postgresql/db:/var/lib/postgresql/data ports: @@ -176,5 +177,28 @@ services: networks: - 'stack' + otel-collector: + depends_on: + clickhouse: + condition: service_healthy + build: + context: ./configs/otel-collector + dockerfile: ./../../otel-collector.dockerfile + environment: + HIVE_OTEL_AUTH_ENDPOINT: 'http://host.docker.internal:3001/otel-auth' + CLICKHOUSE_PROTOCOL: 'http' + CLICKHOUSE_HOST: clickhouse + CLICKHOUSE_PORT: 8123 + CLICKHOUSE_USERNAME: test + CLICKHOUSE_PASSWORD: test + volumes: + - ./configs/otel-collector/builder-config.yaml:/builder-config.yaml + - ./configs/otel-collector/config.yaml:/etc/otel-config.yaml + ports: + - '4317:4317' + - '4318:4318' + networks: + - 'stack' + networks: stack: {} diff --git a/docker/docker.hcl b/docker/docker.hcl index 0910aadbdc..4b8ad0ead1 100644 --- a/docker/docker.hcl +++ b/docker/docker.hcl @@ -82,6 +82,13 @@ target "router-base" { } } +target "otel-collector-base" { + dockerfile = "${PWD}/docker/otel-collector.dockerfile" + args = { + RELEASE = "${RELEASE}" + } +} + target "cli-base" { dockerfile = "${PWD}/docker/cli.dockerfile" args = { @@ -370,6 +377,21 @@ target "apollo-router" { ] } +target "otel-collector" { + inherits = ["otel-collector-base", get_target()] + context = "${PWD}/docker/configs/otel-collector" + args = { + IMAGE_TITLE = "graphql-hive/otel-collector" + IMAGE_DESCRIPTION = "OTEL Collector for GraphQL Hive." + } + tags = [ + local_image_tag("otel-collector"), + stable_image_tag("otel-collector"), + image_tag("otel-collector", COMMIT_SHA), + image_tag("otel-collector", BRANCH_NAME) + ] +} + target "cli" { inherits = ["cli-base", get_target()] context = "${PWD}/packages/libraries/cli" @@ -400,7 +422,8 @@ group "build" { "server", "commerce", "composition-federation-2", - "app" + "app", + "otel-collector" ] } @@ -416,7 +439,8 @@ group "integration-tests" { "usage", "webhooks", "server", - "composition-federation-2" + "composition-federation-2", + "otel-collector" ] } diff --git a/docker/otel-collector.dockerfile b/docker/otel-collector.dockerfile new file mode 100644 index 0000000000..3b17da2505 --- /dev/null +++ b/docker/otel-collector.dockerfile @@ -0,0 +1,34 @@ +FROM scratch AS config + +COPY builder-config.yaml . +COPY extension-hiveauth/ ./extension-hiveauth/ + +FROM golang:1.23.7-bookworm AS builder + +ARG OTEL_VERSION=0.122.0 + +WORKDIR /build + +RUN go install go.opentelemetry.io/collector/cmd/builder@v${OTEL_VERSION} + +# Copy the manifest file and other necessary files +COPY --from=config builder-config.yaml . +COPY --from=config extension-hiveauth/ ./extension-hiveauth/ + +# Build the custom collector +RUN CGO_ENABLED=0 builder --config=/build/builder-config.yaml + +# Stage 2: Final Image +FROM alpine:3.14 + +WORKDIR /app + +# Copy the generated collector binary from the builder stage +COPY --from=builder /build/otelcol-custom . +COPY config.yaml /etc/otel-config.yaml + +# Expose necessary ports +EXPOSE 4317/tcp 4318/tcp 13133/tcp + +# Set the default command +CMD ["./otelcol-custom", "--config=/etc/otel-config.yaml"] diff --git a/load-tests/otel-traces/README.md b/load-tests/otel-traces/README.md new file mode 100644 index 0000000000..0e04d8e99a --- /dev/null +++ b/load-tests/otel-traces/README.md @@ -0,0 +1,27 @@ +# OTEL Trace Stress Test + +The purpose of this script is to see how our infrastructure responds to high loads and cardinality. + +The affected components are: + +- otel trace collector +- clickhouse (cloud) + +## Running the script + +The following environment variabels are required + +``` +OTEL_ENDPOINT +HIVE_ORGANIZATION_ACCESS_TOKEN +HIVE_TARGET_REF +``` + +**Example:** + +```sh +OTEL_ENDPOINT=https://api.hiveready.dev/otel/v1/traces \ + HIVE_ORGANIZATION_ACCESS_TOKEN="" \ + HIVE_TARGET_REF="the-guild/hive/dev" \ + k6 run load-tests/otel-traces/test.ts +``` diff --git a/load-tests/otel-traces/package.json b/load-tests/otel-traces/package.json new file mode 100644 index 0000000000..2fee0962f6 --- /dev/null +++ b/load-tests/otel-traces/package.json @@ -0,0 +1,7 @@ +{ + "name": "loade-test-otel-traces", + "private": true, + "devDependencies": { + "@types/k6": "1.2.0" + } +} diff --git a/load-tests/otel-traces/test.ts b/load-tests/otel-traces/test.ts new file mode 100644 index 0000000000..0255a20011 --- /dev/null +++ b/load-tests/otel-traces/test.ts @@ -0,0 +1,259 @@ +import './bru.ts'; +import { expect } from 'https://jslib.k6.io/k6-testing/0.5.0/index.js'; +import { randomIntBetween, randomString } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'; +import * as immer from 'https://unpkg.com/immer@10.1.3/dist/immer.mjs'; +import { check } from 'k6'; +import http from 'k6/http'; + +// prettier-ignore +globalThis.process = { env: {} }; + +// Cardinality Variables Start +const countUniqueErrorCodes = 1_000; +const countUniqueClients = 1_000; +const appVersionsPerClient = 1_000; + +// Cardinality Variables End +// +export const options = { + scenarios: { + constant_rps: { + executor: 'constant-arrival-rate', + rate: __ENV.REQUESTS_PER_SECOND ?? 10, // requests per second + timeUnit: '1s', // 50 requests per 1 second + duration: __ENV.DURATION, // how long to run + preAllocatedVUs: 10, // number of VUs to pre-allocate + maxVUs: 50, // max number of VUs + }, + }, +}; + +const otelEndpointUrl = __ENV.OTEL_ENDPOINT || 'http://localhost:4318/v1/traces'; +console.log( + `Endpoint: ${otelEndpointUrl}. (Overwrite using the OTEL_ENDPOINT environment variable)`, +); + +const HIVE_ORGANIZATION_ACCESS_TOKEN = __ENV.HIVE_ORGANIZATION_ACCESS_TOKEN; +if (!HIVE_ORGANIZATION_ACCESS_TOKEN) { + throw new Error('Environment variable HIVE_ORGANIZATION_ACCESS_TOKEN is missing.'); +} + +const HIVE_TARGET_REF = __ENV.HIVE_TARGET_REF; +if (!HIVE_TARGET_REF) { + throw new Error('Environment variable HIVE_TARGET_REF is missing.'); +} + +// A helper to generate a random 16-byte trace/span ID in hex +function randomId(bytes: number = 32): string { + let traceId = ''; + for (let i = 0; i < bytes; i++) { + // generate random nibble (0–15) + const nibble = Math.floor(Math.random() * 16); + traceId += nibble.toString(16); + } + + // ensure not all zero (very unlikely) + if (/^0+$/.test(traceId)) { + return randomId(bytes); + } + + return traceId; +} + +function toTimeUnixNano(date = new Date()) { + const milliseconds = date.getTime(); // ms since epoch + const nanoseconds = BigInt(milliseconds) * 1_000_000n; // ns = ms × 1_000_000 + return nanoseconds; +} + +function getRandomIndex(length: number) { + return Math.floor(Math.random() * length); +} + +function randomArrayItem(arr: Array) { + return arr[getRandomIndex(arr.length)]; +} + +const clientNames = new Array(countUniqueClients) + .fill(null) + .map(() => randomString(randomIntBetween(5, 30))); + +const appVersions = new Map>(); + +for (const name of clientNames) { + const versions = new Array(); + for (let i = 0; i <= appVersionsPerClient; i++) { + versions.push(randomString(20)); + } + appVersions.set(name, versions); +} + +function generateRandomClient() { + const name = randomArrayItem(clientNames); + const version = randomArrayItem(appVersions.get(name)!); + + return { + name, + version, + }; +} + +const errorCodes = new Array(countUniqueErrorCodes) + .fill(null) + .map(() => randomString(randomIntBetween(3, 30))); + +function getRandomErrorCodes() { + if (randomIntBetween(0, 10) > 3) { + return []; + } + + if (randomIntBetween(0, 10) > 3) { + return new Array(randomIntBetween(1, 10)).fill(null).map(() => randomArrayItem(errorCodes)); + } + + return [randomArrayItem(errorCodes)]; +} + +// graphql query document size +// operation name length +// + +const references: Array = [ + open('./../../scripts/seed-traces/sample-introspection.json'), + open('./../../scripts/seed-traces/sample-my-profile.json'), + open('./../../scripts/seed-traces/sample-products-overview.json'), + open('./../../scripts/seed-traces/sample-user-review.json'), + open('./../../scripts/seed-traces/sample-user-review-error-missing-variables.json'), + open('./../../scripts/seed-traces/sample-user-review-not-found.json'), +].map(res => JSON.parse(res)); + +function mutate(currentTime: Date, reference: Reference) { + const newTraceId = randomId(); + const newSpanIds = new Map(); + + function getNewSpanId(spanId: string) { + let newSpanId = newSpanIds.get(spanId); + if (!newSpanId) { + newSpanId = randomId(16); + newSpanIds.set(spanId, newSpanId); + } + + return newSpanId; + } + + let rootTrace: + | Reference[number]['resourceSpans'][number]['scopeSpans'][number]['spans'][number] + | null = null; + + for (const payload of reference) { + for (const resourceSpan of payload.resourceSpans) { + for (const scopeSpan of resourceSpan.scopeSpans) { + for (const span of scopeSpan.spans) { + if (span.parentSpanId === undefined) { + rootTrace = span; + const client = generateRandomClient(); + + rootTrace.attributes.push( + { + key: 'hive.client.name', + value: { stringValue: client.name }, + }, + { + key: 'hive.client.version', + value: { stringValue: client.version }, + }, + // TODO: actually calculate this based on the operation. + { + key: 'hive.graphql.operation.hash', + value: { stringValue: randomString(20) }, + }, + ); + + const errors = getRandomErrorCodes(); + + if (errors.length) { + rootTrace.attributes.push( + { + key: 'hive.graphql.error.codes', + value: { + arrayValue: { + values: errors.map(code => ({ stringValue: code })), + }, + }, + }, + { + key: 'hive.graphql.error.count', + value: { intValue: errors.length }, + }, + ); + } + break; + } + } + } + } + } + + if (!rootTrace) { + throw new Error('Parent Span must always be the first span in the file.'); + } + + const startTime = BigInt(rootTrace.startTimeUnixNano); + const currentTimeB = toTimeUnixNano(currentTime); + + for (const payload of reference) { + for (const resourceSpans of payload.resourceSpans) { + for (const scopeSpan of resourceSpans.scopeSpans) { + for (const span of scopeSpan.spans) { + if (span.parentSpanId) { + span.parentSpanId = getNewSpanId(span.parentSpanId); + } + + span.spanId = getNewSpanId(span.spanId); + span.traceId = newTraceId; + + const spanStartTime = BigInt(span.startTimeUnixNano); + const spanEndTime = BigInt(span.endTimeUnixNano); + const spanDuration = spanEndTime - spanStartTime; + const spanOffset = spanStartTime - startTime; + const newStartTime = currentTimeB + spanOffset; + span.startTimeUnixNano = newStartTime.toString(); + span.endTimeUnixNano = (newStartTime + spanDuration).toString(); + + if (span.events.length) { + for (const event of span.events) { + const spanStartTime = BigInt(event.timeUnixNano); + const spanOffset = spanStartTime - startTime; + const newStartTime = currentTimeB + spanOffset; + event.timeUnixNano = newStartTime.toString(); + } + } + } + } + } + } +} + +function createTrace(date: Date, reference: Reference) { + return immer.produce(reference, draft => mutate(date, draft)); +} + +export default function () { + const data = new Array(50).fill(null).flatMap(() => { + const reference = randomArrayItem(references); + const tracePayloads = createTrace(new Date(), reference); + return tracePayloads.flatMap(payload => payload.resourceSpans); + }); + + const response = http.post(otelEndpointUrl, JSON.stringify({ resourceSpans: data }), { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${HIVE_ORGANIZATION_ACCESS_TOKEN}`, + 'x-hive-target-ref': HIVE_TARGET_REF, + }, + }); + + check(response, { + 'is status 200': r => r.status === 200, + }); +} diff --git a/package.json b/package.json index c583ec7291..4531784174 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "docker:override-up": "docker compose -f ./docker/docker-compose.override.yml up -d --remove-orphans", "env:sync": "tsx scripts/sync-env-files.ts", "generate": "pnpm --filter @hive/storage db:generate && pnpm graphql:generate", - "graphql:generate": "graphql-codegen --config codegen.mts", + "graphql:generate": "VERBOSE=1 graphql-codegen --config codegen.mts", "graphql:generate:watch": "pnpm graphql:generate --watch", "integration:prepare": "cd integration-tests && ./local.sh", "lint": "eslint --cache --ignore-path .gitignore \"{packages,cypress}/**/*.{ts,tsx,graphql}\"", diff --git a/packages/libraries/cli/examples/getting-started/products.graphql b/packages/libraries/cli/examples/getting-started/products.graphql new file mode 100644 index 0000000000..539fa6c70e --- /dev/null +++ b/packages/libraries/cli/examples/getting-started/products.graphql @@ -0,0 +1,10 @@ +extend type Query { + topProducts(first: Int = 5): [Product] +} + +type Product @key(fields: "upc") { + upc: String! + name: String + price: Int + weight: Int +} diff --git a/packages/libraries/cli/examples/getting-started/reviews.graphql b/packages/libraries/cli/examples/getting-started/reviews.graphql new file mode 100644 index 0000000000..bee04bb5ae --- /dev/null +++ b/packages/libraries/cli/examples/getting-started/reviews.graphql @@ -0,0 +1,17 @@ +type Review @key(fields: "id") { + id: ID! + body: String + author: User @provides(fields: "username") + product: Product +} + +extend type User @key(fields: "id") { + id: ID! @external + username: String @external + reviews: [Review] +} + +extend type Product @key(fields: "upc") { + upc: String! @external + reviews: [Review] +} diff --git a/packages/libraries/cli/examples/getting-started/users.graphql b/packages/libraries/cli/examples/getting-started/users.graphql new file mode 100644 index 0000000000..8e2ef4578e --- /dev/null +++ b/packages/libraries/cli/examples/getting-started/users.graphql @@ -0,0 +1,11 @@ +extend type Query { + me: User + user(id: ID!): User + users: [User] +} + +type User @key(fields: "id") { + id: ID! + name: String + username: String +} diff --git a/packages/migrations/src/clickhouse-actions/015-otel-trace.ts b/packages/migrations/src/clickhouse-actions/015-otel-trace.ts new file mode 100644 index 0000000000..784763f8f9 --- /dev/null +++ b/packages/migrations/src/clickhouse-actions/015-otel-trace.ts @@ -0,0 +1,316 @@ +import type { Action } from '../clickhouse'; + +export const action: Action = async exec => { + // Base tables as created by otel-exporter clickhouse + await exec(` + CREATE TABLE IF NOT EXISTS "otel_traces" ( + "Timestamp" DateTime64(9, 'UTC') CODEC(Delta(8), ZSTD(1)) + , "TraceId" String CODEC(ZSTD(1)) + , "SpanId" String CODEC(ZSTD(1)) + , "ParentSpanId" String CODEC(ZSTD(1)) + , "TraceState" String CODEC(ZSTD(1)) + , "SpanName" String CODEC(ZSTD(1)) + , "SpanKind" LowCardinality(String) CODEC(ZSTD(1)) + , "ServiceName" LowCardinality(String) CODEC(ZSTD(1)) + , "ResourceAttributes" Map(LowCardinality(String), String) CODEC(ZSTD(1)) + , "ScopeName" String CODEC(ZSTD(1)) + , "ScopeVersion" String CODEC(ZSTD(1)) + , "SpanAttributes" Map(LowCardinality(String), String) CODEC(ZSTD(1)) + , "Duration" UInt64 CODEC(ZSTD(1)) + , "StatusCode" LowCardinality(String) CODEC(ZSTD(1)) + , "StatusMessage" String CODEC(ZSTD(1)) + , "Events.Timestamp" Array(DateTime64(9, 'UTC')) CODEC(ZSTD(1)) + , "Events.Name" Array(LowCardinality(String)) CODEC(ZSTD(1)) + , "Events.Attributes" Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)) + , "Links.TraceId" Array(String) CODEC(ZSTD(1)) + , "Links.SpanId" Array(String) CODEC(ZSTD(1)) + , "Links.TraceState" Array(String) CODEC(ZSTD(1)) + , "Links.Attributes" Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)) + , INDEX "idx_trace_id" "TraceId" TYPE bloom_filter(0.001) GRANULARITY 1 + , INDEX "idx_res_attr_key" mapKeys("ResourceAttributes") TYPE bloom_filter(0.01) GRANULARITY 1 + , INDEX "idx_res_attr_value" mapValues("ResourceAttributes") TYPE bloom_filter(0.01) GRANULARITY 1 + , INDEX "idx_span_attr_key" mapKeys("SpanAttributes") TYPE bloom_filter(0.01) GRANULARITY 1 + , INDEX "idx_span_attr_value" mapValues("SpanAttributes") TYPE bloom_filter(0.01) GRANULARITY 1 + , INDEX "idx_duration" Duration TYPE minmax GRANULARITY 1 + ) + ENGINE = MergeTree + PARTITION BY toDate("Timestamp") + ORDER BY ( + "ServiceName" + , "SpanName" + , toDateTime("Timestamp") + ) + TTL toDate("Timestamp") + toIntervalDay(365) + SETTINGS + index_granularity = 8192 + , ttl_only_drop_parts = 1 + `); + + await exec(` + CREATE TABLE IF NOT EXISTS "otel_traces_trace_id_ts" ( + "TraceId" String CODEC(ZSTD(1)) + , "Start" DateTime CODEC(Delta(4), ZSTD(1)) + , "End" DateTime CODEC(Delta(4), ZSTD(1)) + , INDEX "idx_trace_id" "TraceId" TYPE bloom_filter(0.01) GRANULARITY 1 + ) + ENGINE = MergeTree + PARTITION BY toDate(Start) + ORDER BY ( + "TraceId" + , "Start" + ) + TTL toDate("Start") + toIntervalDay(365) + SETTINGS + index_granularity = 8192 + , ttl_only_drop_parts = 1 + `); + + await exec(` + CREATE MATERIALIZED VIEW IF NOT EXISTS "otel_traces_trace_id_ts_mv" TO "otel_traces_trace_id_ts" ( + "TraceId" String + , "Start" DateTime64(9) + , "End" DateTime64(9) + ) + AS ( + SELECT + "TraceId" + , min("Timestamp") AS "Start" + , max("Timestamp") AS "End" + FROM + "otel_traces" + WHERE + "TraceId" != '' + GROUP BY + "TraceId" + ) + `); + + // Table and MV for trace overview and filtering + await exec(` + CREATE TABLE IF NOT EXISTS "otel_traces_normalized" ( + "target_id" LowCardinality(String) CODEC(ZSTD(1)) + , "trace_id" String CODEC(ZSTD(1)) + , "span_id" String CODEC(ZSTD(1)) + , "timestamp" DateTime('UTC') CODEC(DoubleDelta, LZ4) + , "duration" UInt64 CODEC(T64, ZSTD(1)) + , "http_status_code" String CODEC(ZSTD(1)) + , "http_method" String CODEC(ZSTD(1)) + , "http_host" String CODEC(ZSTD(1)) + , "http_route" String CODEC(ZSTD(1)) + , "http_url" String CODEC(ZSTD(1)) + , "client_name" String Codec(ZSTD(1)) + , "client_version" String Codec(ZSTD(1)) + , "graphql_operation_name" String CODEC(ZSTD(1)) + , "graphql_operation_type" LowCardinality(String) CODEC(ZSTD(1)) + , "graphql_operation_document" String CODEC(ZSTD(1)) + , "graphql_operation_hash" String CODEC(ZSTD(1)) + , "graphql_error_count" UInt32 CODEC(T64, ZSTD(1)) + , "graphql_error_codes" Array(LowCardinality(String)) CODEC(ZSTD(1)) + , "subgraph_names" Array(LowCardinality(String)) CODEC(ZSTD(1)) + , INDEX "idx_duration" "duration" TYPE minmax GRANULARITY 1 + , INDEX "idx_http_status_code" "http_status_code" TYPE bloom_filter(0.01) GRANULARITY 1 + , INDEX "idx_http_method" "http_method" TYPE bloom_filter(0.01) GRANULARITY 1 + , INDEX "idx_http_host" "http_host" TYPE bloom_filter(0.01) GRANULARITY 1 + , INDEX "idx_http_route" "http_route" TYPE bloom_filter(0.01) GRANULARITY 1 + , INDEX "idx_http_url" "http_url" TYPE bloom_filter(0.01) GRANULARITY 1 + , INDEX "idx_client_name" "client_name" TYPE bloom_filter(0.01) GRANULARITY 1 + , INDEX "idx_client_version" "client_version" TYPE bloom_filter(0.01) GRANULARITY 1 + , INDEX "idx_graphql_operation_name" "graphql_operation_name" TYPE bloom_filter(0.01) GRANULARITY 1 + , INDEX "idx_graphql_operation_type" "graphql_operation_type" TYPE bloom_filter(0.01) GRANULARITY 1 + , INDEX "idx_graphql_error_codes" "graphql_error_codes" TYPE bloom_filter(0.01) GRANULARITY 1 + , INDEX "idx_subgraph_names" "subgraph_names" TYPE bloom_filter(0.01) GRANULARITY 1 + ) + ENGINE = MergeTree + PARTITION BY toDate("timestamp") + ORDER BY ("target_id", "timestamp") + TTL toDateTime(timestamp) + toIntervalDay(365) + SETTINGS + index_granularity = 8192 + , ttl_only_drop_parts = 1 + `); + + // You might be wondering why we parse the data in such a weird way. + // This was the smartest I way I came up that did not require introducing additional span attribute validation logic on the gateway. + // We want to avoid inserts failing due to a column type-mismatch at any chance, since we are doing batch inserts and one fault record + // could prevent all other inserts within the same batch from happening. + // + // The idea here is to attempt verifying that the input is "array"-like and if so parse it as safe as possible. + // If the input is not "array"-like we just insert an empty array and move on. + // + // Later on, we could think about actually rejecting incorrect span values on the otel-collector business logic. + // + // ``` + // if( + // JSONType(toString("SpanAttributes"['hive.graphql.error.codes'])) = 'Array', + // arrayFilter( + // -- Filter out empty values + // x -> notEquals(x, ''), + // arrayMap( + // -- If the user provided something non-stringy, this returns '', which is fine imho + // x -> JSONExtractString(x), + // JSONExtractArrayRaw(toString("SpanAttributes"['hive.graphql.error.codes'])) + // ) + // ), + // [] + // ) + // ``` + + await exec(` + CREATE MATERIALIZED VIEW IF NOT EXISTS "otel_traces_normalized_mv" TO "otel_traces_normalized" ( + "target_id" LowCardinality(String) + , "trace_id" String + , "span_id" String + , "timestamp" DateTime('UTC') + , "duration" UInt64 + , "http_status_code" String + , "http_host" String + , "http_method" String + , "http_route" String + , "http_url" String + , "client_name" String + , "client_version" String + , "graphql_operation_name" String + , "graphql_operation_type" LowCardinality(String) + , "graphql_operation_document" String + , "graphql_operation_hash" String + , "graphql_error_count" UInt32 + , "graphql_error_codes" Array(LowCardinality(String)) + , "subgraph_names" Array(String) + ) + AS ( + SELECT + toLowCardinality("SpanAttributes"['hive.target_id']) AS "target_id" + , "TraceId" as "trace_id" + , "SpanId" AS "span_id" + , toDateTime("Timestamp", 'UTC') AS "timestamp" + , "Duration" AS "duration" + , "SpanAttributes"['http.status_code'] AS "http_status_code" + , "SpanAttributes"['http.host'] AS "http_host" + , "SpanAttributes"['http.method'] AS "http_method" + , "SpanAttributes"['http.route'] AS "http_route" + , "SpanAttributes"['http.url'] AS "http_url" + , "SpanAttributes"['hive.client.name'] AS "client_name" + , "SpanAttributes"['hive.client.version'] AS "client_version" + , "SpanAttributes"['graphql.operation.name'] AS "graphql_operation_name" + , toLowCardinality("SpanAttributes"['graphql.operation.type']) AS "graphql_operation_type" + , "SpanAttributes"['graphql.document'] AS "graphql_operation_document" + , "SpanAttributes"['hive.graphql.operation.hash'] AS "graphql_operation_hash" + , toInt64OrZero("SpanAttributes"['hive.graphql.error.count']) AS "graphql_error_count" + , if( + JSONType(toString("SpanAttributes"['hive.graphql.error.codes'])) = 'Array', + arrayFilter( + x -> notEquals(x, ''), + arrayMap( + x -> JSONExtractString(x), + JSONExtractArrayRaw(toString("SpanAttributes"['hive.graphql.error.codes'])) + ) + ), + [] + ) AS "graphql_error_codes" + , if( + JSONType(toString("SpanAttributes"['hive.gateway.operation.subgraph.names'])) = 'Array', + arrayFilter( + x -> notEquals(x, ''), + arrayMap( + x -> JSONExtractString(x), + JSONExtractArrayRaw(toString("SpanAttributes"['hive.gateway.operation.subgraph.names'])) + ) + ), + [] + ) AS "subgraph_names" + FROM + "otel_traces" + WHERE + empty("ParentSpanId") + AND notEmpty("SpanAttributes"['hive.graphql']) + ) + `); + + // These can be used for dedicated subgraph views + + await exec(` + CREATE TABLE IF NOT EXISTS "otel_subgraph_spans" ( + "target_id" LowCardinality(String) CODEC(ZSTD(1)) + , "subgraph_name" String CODEC(ZSTD(1)) + , "trace_id" String CODEC(ZSTD(1)) + , "span_id" String CODEC(ZSTD(1)) + , "timestamp" DateTime('UTC') CODEC(DoubleDelta, LZ4) + , "duration" UInt64 CODEC(T64, ZSTD(1)) + , "http_status_code" String CODEC(ZSTD(1)) + , "http_method" String CODEC(ZSTD(1)) + , "http_host" String CODEC(ZSTD(1)) + , "http_route" String CODEC(ZSTD(1)) + , "http_url" String CODEC(ZSTD(1)) + , "graphql_operation_name" String CODEC(ZSTD(1)) + , "graphql_operation_type" LowCardinality(String) CODEC(ZSTD(1)) + , "graphql_operation_document" String CODEC(ZSTD(1)) + , "graphql_error_count" UInt32 CODEC(T64, ZSTD(1)) + , "graphql_error_codes" Array(LowCardinality(String)) CODEC(ZSTD(1)) + ) + ENGINE = MergeTree + PARTITION BY toDate("timestamp") + ORDER BY ("target_id", "subgraph_name", "timestamp") + TTL toDateTime(timestamp) + toIntervalDay(365) + SETTINGS + index_granularity = 8192 + , ttl_only_drop_parts = 1 + `); + + await exec(` + CREATE MATERIALIZED VIEW IF NOT EXISTS "otel_subgraph_spans_mv" TO "otel_subgraph_spans" ( + "target_id" LowCardinality(String) + , "subgraph_name" Array(String) + , "trace_id" String + , "span_id" String + , "timestamp" DateTime('UTC') + , "duration" UInt64 + , "http_status_code" String + , "http_host" String + , "http_method" String + , "http_route" String + , "http_url" String + , "client_name" String + , "client_version" String + , "graphql_operation_name" String + , "graphql_operation_type" LowCardinality(String) + , "graphql_operation_document" String + , "graphql_error_count" UInt32 + , "graphql_error_codes" Array(LowCardinality(String)) + ) + AS ( + SELECT + toLowCardinality("SpanAttributes"['hive.target_id']) AS "target_id" + , "SpanAttributes"['hive.graphql.subgraph.name'] AS "subgraph_name" + , "TraceId" as "trace_id" + , "SpanId" AS "span_id" + , toDateTime("Timestamp", 'UTC') AS "timestamp" + , "Duration" AS "duration" + , "SpanAttributes"['http.status_code'] AS "http_status_code" + , "SpanAttributes"['http.host'] AS "http_host" + , "SpanAttributes"['http.method'] AS "http_method" + , "SpanAttributes"['http.route'] AS "http_route" + , "SpanAttributes"['http.url'] AS "http_url" + , "SpanAttributes"['hive.client.name'] AS "client_name" + , "SpanAttributes"['hive.client.version'] AS "client_version" + , "SpanAttributes"['graphql.operation.name'] AS "graphql_operation_name" + , toLowCardinality("SpanAttributes"['graphql.operation.type']) AS "graphql_operation_type" + , "SpanAttributes"['graphql.document'] AS "graphql_operation_document" + , toInt64OrZero("SpanAttributes"['hive.graphql.error.count']) AS "graphql_error_count" + , if( + JSONType(toString("SpanAttributes"['hive.graphql.error.codes'])) = 'Array', + arrayFilter( + x -> notEquals(x, ''), + arrayMap( + x -> JSONExtractString(x), + JSONExtractArrayRaw(toString("SpanAttributes"['hive.graphql.error.codes'])) + ) + ), + [] + ) AS "graphql_error_codes" + FROM + "otel_traces" + WHERE + notEmpty("SpanAttributes"['hive.graphql.subgraph.name']) + ) + `); +}; diff --git a/packages/migrations/src/clickhouse.ts b/packages/migrations/src/clickhouse.ts index f63b558602..56e617d4bf 100644 --- a/packages/migrations/src/clickhouse.ts +++ b/packages/migrations/src/clickhouse.ts @@ -175,6 +175,7 @@ export async function migrateClickHouse( import('./clickhouse-actions/012-coordinates-typename-index'), import('./clickhouse-actions/013-apply-ttl'), import('./clickhouse-actions/014-audit-logs-access-token'), + import('./clickhouse-actions/015-otel-trace'), ]); async function actionRunner(action: Action, index: number) { diff --git a/packages/services/api/src/modules/auth/lib/authz.ts b/packages/services/api/src/modules/auth/lib/authz.ts index faa12454d7..f4661b8457 100644 --- a/packages/services/api/src/modules/auth/lib/authz.ts +++ b/packages/services/api/src/modules/auth/lib/authz.ts @@ -412,6 +412,7 @@ const permissionsByLevel = { z.literal('laboratory:modifyPreflightScript'), z.literal('schema:compose'), z.literal('usage:report'), + z.literal('traces:report'), ], service: [ z.literal('schemaCheck:create'), diff --git a/packages/services/api/src/modules/operations/index.ts b/packages/services/api/src/modules/operations/index.ts index a8481106a4..41f70b8a5a 100644 --- a/packages/services/api/src/modules/operations/index.ts +++ b/packages/services/api/src/modules/operations/index.ts @@ -2,6 +2,7 @@ import { createModule } from 'graphql-modules'; import { ClickHouse } from './providers/clickhouse-client'; import { OperationsManager } from './providers/operations-manager'; import { OperationsReader } from './providers/operations-reader'; +import { Traces } from './providers/traces'; import { resolvers } from './resolvers.generated'; import typeDefs from './module.graphql'; @@ -10,5 +11,5 @@ export const operationsModule = createModule({ dirname: __dirname, typeDefs, resolvers, - providers: [OperationsManager, OperationsReader, ClickHouse], + providers: [OperationsManager, OperationsReader, ClickHouse, Traces], }); diff --git a/packages/services/api/src/modules/operations/module.graphql.mappers.ts b/packages/services/api/src/modules/operations/module.graphql.mappers.ts index 19519e56f0..099bac718b 100644 --- a/packages/services/api/src/modules/operations/module.graphql.mappers.ts +++ b/packages/services/api/src/modules/operations/module.graphql.mappers.ts @@ -1,5 +1,9 @@ +import type Dataloader from 'dataloader'; import type { ClientStatsValues, OperationStatsValues, PageInfo } from '../../__generated__/types'; import type { DateRange } from '../../shared/entities'; +import { Span, Trace } from './providers/traces'; + +// import { SqlValue } from './providers/sql'; type Connection = { pageInfo: PageInfo; @@ -41,3 +45,20 @@ export interface DurationValuesMapper { p95: number | null; p99: number | null; } + +export type TracesFilterOptionsMapper = { + // ANDs: readonly SqlValue[]; + loader: Dataloader< + { + key: string; + columnExpression: string; + limit: number | null; + arrayJoinColumn: string | null; + }, + { value: string; count: number }[], + string + >; +}; + +export type TraceMapper = Trace; +export type SpanMapper = Span; diff --git a/packages/services/api/src/modules/operations/module.graphql.ts b/packages/services/api/src/modules/operations/module.graphql.ts index 00d2e4c7e4..e1c4735132 100644 --- a/packages/services/api/src/modules/operations/module.graphql.ts +++ b/packages/services/api/src/modules/operations/module.graphql.ts @@ -259,6 +259,217 @@ export default gql` body: String! @tag(name: "public") } + type TraceConnection { + edges: [TraceEdge!]! + pageInfo: PageInfo! + } + + type TraceEdge { + node: Trace! + cursor: String! + } + + type Trace { + id: ID! + timestamp: DateTime! + operationName: String + operationType: GraphQLOperationType + """ + The Hash of the GraphQL operation. + """ + operationHash: ID + """ + Total duration of the trace. + """ + duration: SafeInt! + """ + The subgraphs called within the trace. + """ + subgraphs: [String!] + """ + Wether the trace is successful. + A trace is a success if no GraphQL errors occured and the HTTP status code is in the 2XX to 3XX range. + """ + success: Boolean! + """ + The client name. + Usually this is the 'x-graphql-client-name' header sent to the gateway. + """ + clientName: String + """ + The client version. + Usually this is the 'x-graphql-client-version' header sent to the gateway. + """ + clientVersion: String + httpStatusCode: String + httpMethod: String + httpHost: String + httpRoute: String + httpUrl: String + + spans: [Span!]! + } + + type SpanEvent { + date: DateTime64! + name: String! + attributes: JSONObject! + } + + type Span { + id: ID! + traceId: ID! + parentId: ID + name: String! + startTime: DateTime64! + duration: SafeInt! + endTime: DateTime64! + resourceAttributes: JSONObject! + spanAttributes: JSONObject! + events: [SpanEvent!]! + } + + input DurationInput { + min: SafeInt + max: SafeInt + } + + input TracesFilterInput { + """ + Time range filter for the traces. + """ + period: DateRangeInput + """ + Duration filter for the traces. + """ + duration: DurationInput + """ + Filter based on trace ID. + """ + traceIds: [ID!] + """ + Filter based on whether the operation is a success. + A operation is successful if no GraphQL error has occured and the result is within the 2XX or 3XX range. + """ + success: [Boolean!] + """ + Filter based on GraphQL error codes (error.extensions.code). + """ + errorCodes: [String!] + """ + Filter based on the operation name. + """ + operationNames: [String!] + """ + Filter based on the operation type. + + A value of 'null' value indicates an unknown operation type. + """ + operationTypes: [GraphQLOperationType] + """ + Filter based on the client name. + """ + clientNames: [String!] + """ + Filter based on the HTTP status code of the request. + """ + httpStatusCodes: [String!] + """ + Filter based on the HTTP method of the request. + """ + httpMethods: [String!] + """ + Filter based on the HTTP host of the request. + """ + httpHosts: [String!] + """ + Filter based on the HTTP route of the request. + """ + httpRoutes: [String!] + """ + Filter based on the HTTP URL of the request. + """ + httpUrls: [String!] + """ + Filter based on called subgraphs. + """ + subgraphNames: [String!] + } + + type TracesFilterOptions { + success: [FilterBooleanOption!]! + """ + Filter based on GraphQL error code. + """ + errorCode(top: Int): [FilterStringOption!]! + operationType: [FilterStringOption!]! + operationName(top: Int): [FilterStringOption!]! + clientName(top: Int): [FilterStringOption!]! + httpStatusCode(top: Int): [FilterStringOption!]! + httpMethod(top: Int): [FilterStringOption!]! + httpHost(top: Int): [FilterStringOption!]! + httpRoute(top: Int): [FilterStringOption!]! + httpUrl(top: Int): [FilterStringOption!]! + subgraphs(top: Int): [FilterStringOption!]! + } + + type FilterStringOption { + value: String! + count: Int! + } + + type FilterBooleanOption { + value: Boolean! + count: Int! + } + + type FilterIntOption { + value: Int! + count: Int! + } + + enum SortDirectionType { + ASC + DESC + } + + enum TracesSortType { + DURATION + TIMESTAMP + } + + input TracesSortInput { + sort: TracesSortType! + direction: SortDirectionType! + } + + type TraceStatusBreakdownBucket { + """ + The time bucket for the data + """ + timeBucketStart: DateTime! + """ + The end of the time bucket for the data + """ + timeBucketEnd: DateTime! + """ + Total amount of ok traces in the bucket. + """ + okCountTotal: SafeInt! + """ + Total amount of error traces in the bucket. + """ + errorCountTotal: SafeInt! + """ + Total amount of ok traces in the bucket based on the filter. + """ + okCountFiltered: SafeInt! + """ + Total mount of error traces in the bucket based on the filter. + """ + errorCountFiltered: SafeInt! + } + extend type Target { requestsOverTime(resolution: Int!, period: DateRangeInput!): [RequestsOverTime!]! totalRequests(period: DateRangeInput!): SafeInt! @@ -266,6 +477,20 @@ export default gql` Retrieve an operation via it's hash. """ operation(hash: ID! @tag(name: "public")): Operation @tag(name: "public") + + """ + Whether the viewer can access OTEL traces + """ + viewerCanAccessTraces: Boolean! + traces( + first: Int + after: String + filter: TracesFilterInput + sort: TracesSortInput + ): TraceConnection! + tracesFilterOptions(filter: TracesFilterInput): TracesFilterOptions! + tracesStatusBreakdown(filter: TracesFilterInput): [TraceStatusBreakdownBucket!]! + trace(traceId: ID!): Trace } extend type Project { diff --git a/packages/services/api/src/modules/operations/providers/clickhouse-client.ts b/packages/services/api/src/modules/operations/providers/clickhouse-client.ts index 8735099df9..17f0b28dfc 100644 --- a/packages/services/api/src/modules/operations/providers/clickhouse-client.ts +++ b/packages/services/api/src/modules/operations/providers/clickhouse-client.ts @@ -120,6 +120,23 @@ export class ClickHouse { }, retry: { calculateDelay: info => { + if ( + info.error.response?.body && + typeof info.error.response.body === 'object' && + 'exception' in info.error.response.body && + typeof info.error.response.body.exception === 'string' + ) { + this.logger.error(info.error.response.body.exception); + // In case of development errors we don't need to retry + // https://github.com/ClickHouse/ClickHouse/blob/eb33caaa13355761e4ceaba4a41b8801161ce327/src/Common/ErrorCodes.cpp#L55 + // //https://github.com/ClickHouse/ClickHouse/blob/eb33caaa13355761e4ceaba4a41b8801161ce327/src/Common/ErrorCodes.cpp#L68C7-L68C9 + if ( + info.error.response.body.exception.startsWith('Code: 47') || + info.error.response.body.exception.startsWith('Code: 62') + ) { + return 0; + } + } span.setAttribute('retry.count', info.attemptCount); if (info.attemptCount >= 6) { diff --git a/packages/services/api/src/modules/operations/providers/operations-reader.ts b/packages/services/api/src/modules/operations/providers/operations-reader.ts index 5b2d94b56d..ba8235bbb0 100644 --- a/packages/services/api/src/modules/operations/providers/operations-reader.ts +++ b/packages/services/api/src/modules/operations/providers/operations-reader.ts @@ -18,7 +18,7 @@ const CoordinateClientNamesGroupModel = z.array( }), ); -function formatDate(date: Date): string { +export function formatDate(date: Date): string { return format(addMinutes(date, date.getTimezoneOffset()), 'yyyy-MM-dd HH:mm:ss'); } diff --git a/packages/services/api/src/modules/operations/providers/traces.ts b/packages/services/api/src/modules/operations/providers/traces.ts new file mode 100644 index 0000000000..5538e85630 --- /dev/null +++ b/packages/services/api/src/modules/operations/providers/traces.ts @@ -0,0 +1,588 @@ +import { Injectable } from 'graphql-modules'; +import { z } from 'zod'; +import { subDays } from '@/lib/date-time'; +import * as GraphQLSchema from '../../../__generated__/types'; +import { HiveError } from '../../../shared/errors'; +import { batch, parseDateRangeInput } from '../../../shared/helpers'; +import { Logger } from '../../shared/providers/logger'; +import { Storage } from '../../shared/providers/storage'; +import { ClickHouse, sql } from './clickhouse-client'; +import { formatDate } from './operations-reader'; +import { SqlValue } from './sql'; + +@Injectable({ + global: true, +}) +export class Traces { + constructor( + private clickHouse: ClickHouse, + private logger: Logger, + private storage: Storage, + ) {} + + async viewerCanAccessTraces(organizationId: string) { + const organization = await this.storage.getOrganization({ organizationId }); + return organization.featureFlags.otelTracing; + } + + private async _guardViewerCanAccessTraces(organizationId: string) { + if (await this.viewerCanAccessTraces(organizationId)) { + return; + } + throw new HiveError("You don't have acces to this feature."); + } + + private _findTraceByTraceId = batch(async (traceIds: Array) => { + this.logger.debug('looking up traces by id (traceIds=%o)', traceIds); + const result = await this.clickHouse.query({ + query: sql` + SELECT + ${traceFields} + FROM + "otel_traces_normalized" + WHERE + "trace_id" IN (${sql.array(traceIds, 'String')}) + LIMIT 1 BY "trace_id" + `, + timeout: 10_000, + queryId: 'Traces.findTraceByTraceId', + }); + + this.logger.debug('found %d traces', result.data.length); + + const lookupMap = new Map(); + const traces = TraceListModel.parse(result.data); + for (const trace of traces) { + lookupMap.set(trace.traceId, trace); + } + + return traceIds.map(traceId => Promise.resolve(lookupMap.get(traceId) ?? null)); + }); + + /** + * Find a specific trace by it's id. + * Uses batching under the hood. + */ + async findTraceById( + organizationId: string, + targetId: string, + traceId: string, + ): Promise { + await this._guardViewerCanAccessTraces(organizationId); + this.logger.debug('find trace by id (targetId=%s, traceId=%s)', targetId, traceId); + const trace = await this._findTraceByTraceId(traceId); + if (!trace) { + this.logger.debug('could not find trace by id (targetId=%s, traceId=%s)', targetId, traceId); + return null; + } + if (trace.targetId !== targetId) { + this.logger.debug( + 'resolved trace target id does not match (targetId=%s, traceId=%s)', + targetId, + traceId, + ); + return null; + } + + this.logger.debug('trace found (targetId=%s, traceId=%s)', targetId, traceId); + + return trace; + } + + async findSpansForTraceId(traceId: string, targetId: string): Promise> { + this.logger.debug('find spans for trace (traceId=%s)', traceId); + const result = await this.clickHouse.query({ + query: sql` + SELECT + ${spanFields} + FROM + "otel_traces" + WHERE + "TraceId" = ${traceId} + AND "SpanAttributes"['hive.target_id'] = ${targetId} + `, + timeout: 10_000, + queryId: 'Traces.findSpansForTraceId', + }); + + return SpanListModel.parse(result.data); + } + + async findTracesForTargetId( + organizationId: string, + targetId: string, + first: number | null, + filter: TraceFilter, + sort: GraphQLSchema.TracesSortInput | null, + cursorStr: string | null, + ) { + function createCursor(trace: Trace) { + return Buffer.from( + JSON.stringify({ + timestamp: trace.timestamp, + traceId: trace.traceId, + duration: sort?.sort === 'DURATION' ? trace.duration : undefined, + } satisfies z.TypeOf), + ).toString('base64'); + } + + function parseCursor(cursor: string) { + const data = PaginatedTraceCursorModel.parse( + JSON.parse(Buffer.from(cursor, 'base64').toString('utf8')), + ); + if (sort?.sort === 'DURATION' && !data.duration) { + throw new HiveError('Invalid cursor provided.'); + } + return data; + } + + await this._guardViewerCanAccessTraces(organizationId); + const limit = first ?? 50; + const cursor = cursorStr ? parseCursor(cursorStr) : null; + + // By default we order by timestamp DESC + // In case a custom sort is provided, we order by duration asc/desc or timestamp asc + const orderByFragment = sql` + ${sort?.sort === 'DURATION' ? sql`"duration" ${sort.direction === 'ASC' ? sql`ASC` : sql`DESC`},` : sql``} + "timestamp" ${sort?.sort === 'TIMESTAMP' && sort?.direction === 'ASC' ? sql`ASC` : sql`DESC`} + , "trace_id" DESC + `; + + let paginationSQLFragmentPart = sql``; + + if (cursor) { + if (sort?.sort === 'DURATION') { + const operator = sort.direction === 'ASC' ? sql`>` : sql`<`; + const durationStr = String(cursor.duration); + paginationSQLFragmentPart = sql` + AND ( + "duration" ${operator} ${durationStr} + OR ( + "duration" = ${durationStr} + AND "timestamp" < ${cursor.timestamp} + ) + OR ( + "duration" = ${durationStr} + AND "timestamp" = ${cursor.timestamp} + AND "trace_id" < ${cursor.traceId} + ) + ) + `; + } /* TIMESTAMP */ else { + const operator = sort?.direction === 'ASC' ? sql`>` : sql`<`; + paginationSQLFragmentPart = sql` + AND ( + ( + "timestamp" = ${cursor.timestamp} + AND "trace_id" < ${cursor.traceId} + ) + OR "timestamp" ${operator} ${cursor.timestamp} + ) + `; + } + } + + const sqlConditions = buildTraceFilterSQLConditions(filter, false); + + const filterSQLFragment = sqlConditions.length + ? sql`AND ${sql.join(sqlConditions, ' AND ')}` + : sql``; + + const tracesQuery = await this.clickHouse.query({ + query: sql` + SELECT + ${traceFields} + FROM + "otel_traces_normalized" + WHERE + target_id = ${targetId} + ${paginationSQLFragmentPart} + ${filterSQLFragment} + ORDER BY + ${orderByFragment} + LIMIT ${sql.raw(String(limit + 1))} + `, + queryId: 'traces', + timeout: 10_000, + }); + + let traces = TraceListModel.parse(tracesQuery.data); + const hasNext = traces.length > limit; + traces = traces.slice(0, limit); + + return { + edges: traces.map(trace => ({ + node: trace, + cursor: createCursor(trace), + })), + pageInfo: { + hasNextPage: hasNext, + hasPreviousPage: false, + endCursor: traces.length ? createCursor(traces[traces.length - 1]) : '', + startCursor: traces.length ? createCursor(traces[0]) : '', + }, + }; + } + + async getTraceStatusBreakdownForTargetId( + organizationId: string, + targetId: string, + filter: TraceFilter, + ) { + await this._guardViewerCanAccessTraces(organizationId); + const sqlConditions = buildTraceFilterSQLConditions(filter, true); + const filterSQLFragment = sqlConditions.length + ? sql`AND ${sql.join(sqlConditions, ' AND ')}` + : sql``; + + const endDate = filter.period?.to ?? new Date(); + const startDate = filter.period?.from ?? subDays(endDate, 14); + + const d = getBucketUnitAndCountNew(startDate, endDate); + + const [countStr, unit] = d.candidate.name.split(' '); + + const bucketStepFunctionName = { + MINUTE: 'addMinutes', + HOUR: 'addHours', + DAY: 'addDays', + WEEK: 'addWeeks', + MONTH: 'addMonths', + }; + + const addIntervalFn = sql.raw( + bucketStepFunctionName[unit as keyof typeof bucketStepFunctionName], + ); + + const result = await this.clickHouse.query({ + query: sql` + WITH "time_bucket_list" AS ( + SELECT + ${addIntervalFn}( + toStartOfInterval( + toDateTime(${formatDate(startDate)}, 'UTC') + , INTERVAL ${sql.raw(d.candidate.name)} + ) + , ("number" + 1) * ${sql.raw(countStr)} + ) AS "time_bucket" + FROM + "system"."numbers" + WHERE "system"."numbers"."number" < ${String(d.buckets)} + ) + SELECT + replaceOne(concat(toDateTime64("time_bucket_list"."time_bucket", 9, 'UTC'), 'Z'), ' ', 'T') AS "timeBucketStart" + , replaceOne(concat( + toDateTime64( + "time_bucket_list"."time_bucket" + + INTERVAL ${sql.raw(d.candidate.name)} + - INTERVAL 1 SECOND + , 9 + , 'UTC' + ) , 'Z') , ' ' , 'T' + ) AS "timeBucketEnd" + , coalesce("t"."ok_count_total", 0) as "okCountTotal" + , coalesce("t"."error_count_total", 0) as "errorCountTotal" + , coalesce("t"."ok_count_filtered", 0) as "okCountFiltered" + , coalesce("t"."error_count_filtered", 0) as "errorCountFiltered" + FROM + "time_bucket_list" + LEFT JOIN + ( + SELECT + toStartOfInterval("timestamp", INTERVAL ${sql.raw(d.candidate.name)}) AS "time_bucket_start" + , sumIf(1, "graphql_error_count" = 0) AS "ok_count_total" + , sumIf(1, "graphql_error_count" != 0) AS "error_count_total" + , sumIf(1, "graphql_error_count" = 0 ${filterSQLFragment}) AS "ok_count_filtered" + , sumIf(1, "graphql_error_count" != 0 ${filterSQLFragment}) AS "error_count_filtered" + FROM + "otel_traces_normalized" + WHERE + "target_id" = ${targetId} + AND "otel_traces_normalized"."timestamp" >= toDateTime(${formatDate(startDate)}, 'UTC') + AND "otel_traces_normalized"."timestamp" <= toDateTime(${formatDate(endDate)}, 'UTC') + GROUP BY + "time_bucket_start" + ) AS "t" + ON "t"."time_bucket_start" = "time_bucket_list"."time_bucket" + `, + queryId: `trace_status_breakdown_for_target_id_`, + timeout: 10_000, + }); + + return TraceStatusBreakdownBucketList.parse(result.data); + } +} + +const traceFields = sql` + "target_id" AS "targetId" + , "trace_id" AS "traceId" + , "span_id" AS "spanId" + , replaceOne(concat(toDateTime64("timestamp", 9, 'UTC'), 'Z'), ' ', 'T') AS "timestamp" + , "http_status_code" AS "httpStatusCode" + , "http_method" AS "httpMethod" + , "http_host" AS "httpHost" + , "http_route" AS "httpRoute" + , "http_url" AS "httpUrl" + , "duration" + , "graphql_operation_name" AS "graphqlOperationName" + , "graphql_operation_document" AS "graphqlOperationDocument" + , "graphql_operation_hash" AS "graphqlOperationHash" + , "client_name" AS "clientName" + , "client_version" AS "clientVersion" + , upper("graphql_operation_type") AS "graphqlOperationType" + , "graphql_error_count" AS "graphqlErrorCount" + , "graphql_error_codes" AS "graphqlErrorCodes" + , "subgraph_names" AS "subgraphNames" +`; + +const TraceModel = z.object({ + traceId: z.string(), + targetId: z.string().uuid(), + spanId: z.string(), + timestamp: z.string(), + httpStatusCode: z.string(), + httpMethod: z.string(), + httpHost: z.string(), + httpRoute: z.string(), + httpUrl: z.string(), + duration: z.string().transform(value => parseFloat(value)), + graphqlOperationName: z + .string() + .nullable() + .transform(value => value || null), + graphqlOperationType: z + .union([z.literal('QUERY'), z.literal('MUTATION'), z.literal('SUBSCRIPTION'), z.literal('')]) + .transform(value => value || null), + graphqlOperationHash: z.string().nullable(), + clientName: z.string().nullable(), + clientVersion: z.string().nullable(), + graphqlErrorCodes: z.array(z.string()).nullable(), + graphqlErrorCount: z.number(), + subgraphNames: z.array(z.string()).transform(s => (s.length === 1 && s.at(0) === '' ? [] : s)), +}); + +export type Trace = z.TypeOf; + +const TraceListModel = z.array(TraceModel); + +type TraceFilter = { + period: null | GraphQLSchema.DateRangeInput; + duration: { + min: number | null; + max: number | null; + } | null; + traceIds: ReadonlyArray | null; + success: ReadonlyArray | null; + errorCodes: ReadonlyArray | null; + operationNames: ReadonlyArray | null; + operationTypes: ReadonlyArray | null; + clientNames: ReadonlyArray | null; + subgraphNames: ReadonlyArray | null; + httpStatusCodes: ReadonlyArray | null; + httpMethods: ReadonlyArray | null; + httpHosts: ReadonlyArray | null; + httpRoutes: ReadonlyArray | null; + httpUrls: ReadonlyArray | null; +}; + +function buildTraceFilterSQLConditions(filter: TraceFilter, skipPeriod: boolean) { + const ANDs: SqlValue[] = []; + + if (filter?.period && !skipPeriod) { + const period = parseDateRangeInput(filter.period); + ANDs.push( + sql`"otel_traces_normalized"."timestamp" >= toDateTime(${formatDate(period.from)}, 'UTC')`, + sql`"otel_traces_normalized"."timestamp" <= toDateTime(${formatDate(period.to)}, 'UTC')`, + ); + } + + if (filter?.duration?.min) { + ANDs.push(sql`"duration" >= ${String(filter.duration.min * 1_000 * 1_000)}`); + } + + if (filter?.duration?.max) { + ANDs.push(sql`"duration" <= ${String(filter.duration.max * 1_000 * 1_000)}`); + } + + if (filter?.traceIds?.length) { + ANDs.push(sql`"trace_id" IN (${sql.array(filter.traceIds, 'String')})`); + } + + // Success based on GraphQL terms + if (filter?.success?.length) { + const hasSuccess = filter.success.includes(true); + const hasError = filter.success.includes(false); + + if (hasSuccess && !hasError) { + ANDs.push( + sql`"graphql_error_count" = 0`, + sql`substring("http_status_code", 1, 1) IN (${sql.array(['2', '3'], 'String')})`, + ); + } else if (hasError && !hasSuccess) { + ANDs.push( + sql`"graphql_error_count" > 0`, + sql`substring("http_status_code", 1, 1) NOT IN (${sql.array(['2', '3'], 'String')})`, + ); + } + } + + if (filter?.errorCodes?.length) { + ANDs.push(sql`hasAny("graphql_error_codes", (${sql.array(filter.errorCodes, 'String')}))`); + } + + if (filter?.operationNames?.length) { + ANDs.push(sql`"graphql_operation_name" IN (${sql.array(filter.operationNames, 'String')})`); + } + + if (filter?.operationTypes?.length) { + ANDs.push( + sql`"graphql_operation_type" IN (${sql.array( + filter.operationTypes.map(value => (value == null ? '' : value.toLowerCase())), + 'String', + )})`, + ); + } + + if (filter?.clientNames?.length) { + ANDs.push(sql`"client_name" IN (${sql.array(filter.clientNames, 'String')})`); + } + + if (filter?.subgraphNames?.length) { + ANDs.push(sql`hasAny("subgraph_names", (${sql.array(filter.subgraphNames, 'String')}))`); + } + + if (filter?.httpStatusCodes?.length) { + ANDs.push(sql`"http_status_code" IN (${sql.array(filter.httpStatusCodes, 'String')})`); + } + + if (filter?.httpMethods?.length) { + ANDs.push(sql`"http_method" IN (${sql.array(filter.httpMethods, 'String')})`); + } + + if (filter?.httpHosts?.length) { + ANDs.push(sql`"http_host" IN (${sql.array(filter.httpHosts, 'String')})`); + } + + if (filter?.httpRoutes?.length) { + ANDs.push(sql`"http_route" IN (${sql.array(filter.httpRoutes, 'String')})`); + } + + if (filter?.httpUrls?.length) { + ANDs.push(sql`"http_url" IN (${sql.array(filter.httpUrls, 'String')})`); + } + + return ANDs; +} + +const IntFromString = z.string().transform(value => parseInt(value, 10)); + +const TraceStatusBreakdownBucket = z.object({ + timeBucketStart: z.string(), + timeBucketEnd: z.string(), + okCountTotal: IntFromString, + errorCountTotal: IntFromString, + okCountFiltered: IntFromString, + errorCountFiltered: IntFromString, +}); + +const TraceStatusBreakdownBucketList = z.array(TraceStatusBreakdownBucket); + +const spanFields = sql` + "TraceId" AS "traceId" + , "SpanId" AS "spanId" + , "SpanName" AS "spanName" + , "ResourceAttributes" AS "resourceAttributes" + , "SpanAttributes" AS "spanAttributes" + , replaceOne(concat(toDateTime64("Timestamp", 9, 'UTC'), 'Z'), ' ', 'T') AS "startDate" + , replaceOne(concat(toDateTime64(addNanoseconds("Timestamp", "Duration"), 9, 'UTC'), 'Z'), ' ', 'T') AS "endDate" + , "Duration" AS "duration" + , "ParentSpanId" AS "parentSpanId" + , arrayMap(x -> replaceOne(concat(toDateTime64(x, 9, 'UTC'), 'Z'), ' ', 'T'),"Events.Timestamp") AS "eventsTimestamps" + , "Events.Name" AS "eventsName" + , "Events.Attributes" AS "eventsAttributes" +`; + +const SpanModel = z + .object({ + traceId: z.string(), + spanId: z.string(), + spanName: z.string(), + resourceAttributes: z.record(z.string(), z.unknown()), + spanAttributes: z.record(z.string(), z.unknown()), + startDate: z.string(), + endDate: z.string(), + duration: z.string().transform(value => parseFloat(value)), + parentSpanId: z.string().transform(value => value || null), + eventsTimestamps: z.array(z.string()), + eventsName: z.array(z.string()), + eventsAttributes: z.array(z.record(z.string(), z.string())), + }) + .transform(({ eventsTimestamps, eventsName, eventsAttributes, ...span }) => ({ + ...span, + events: eventsTimestamps.map((date, index) => ({ + date, + name: eventsName[index], + attributes: eventsAttributes[index], + })), + })); + +const SpanListModel = z.array(SpanModel); + +export type Span = z.TypeOf; + +type BucketCandidate = { + name: string; + seconds: number; +}; + +const bucketCanditates: Array = [ + { + name: '1 MINUTE', + seconds: 60, + }, + { + name: '5 MINUTE', + seconds: 60 * 5, + }, + { name: '1 HOUR', seconds: 60 * 60 }, + { name: '4 HOUR', seconds: 60 * 60 * 4 }, + { name: '6 HOUR', seconds: 60 * 60 * 6 }, + { name: '1 DAY', seconds: 60 * 60 * 24 }, + { name: '1 WEEK', seconds: 60 * 60 * 24 * 7 }, + { name: '1 MONTH', seconds: 60 * 60 * 24 * 30 }, +]; + +function getBucketUnitAndCountNew(startDate: Date, endDate: Date, targetBuckets: number = 50) { + const diffSeconds = Math.floor((endDate.getTime() - startDate.getTime()) / 1000); + + let best = { + candidate: bucketCanditates[0], + buckets: Math.floor(diffSeconds / bucketCanditates[0].seconds), + }; + + let bestDiff = Number.POSITIVE_INFINITY; + + for (const candidate of bucketCanditates) { + const buckets = Math.floor(diffSeconds / candidate.seconds); + + const diff = Math.abs(buckets - targetBuckets); + if (diff < bestDiff) { + bestDiff = diff; + best = { + candidate, + buckets, + }; + } + } + + return best; +} + +/** + * All sortable fields (duration, timestamp), must be part of the cursor + */ +const PaginatedTraceCursorModel = z.object({ + traceId: z.string(), + timestamp: z.string(), + duration: z.number().optional(), +}); diff --git a/packages/services/api/src/modules/operations/resolvers/Span.ts b/packages/services/api/src/modules/operations/resolvers/Span.ts new file mode 100644 index 0000000000..0c4a4f6dcc --- /dev/null +++ b/packages/services/api/src/modules/operations/resolvers/Span.ts @@ -0,0 +1,28 @@ +import type { SpanResolvers } from './../../../__generated__/types'; + +/* + * Note: This object type is generated because "SpanMapper" is declared. This is to ensure runtime safety. + * + * When a mapper is used, it is possible to hit runtime errors in some scenarios: + * - given a field name, the schema type's field type does not match mapper's field type + * - or a schema type's field does not exist in the mapper's fields + * + * If you want to skip this file generation, remove the mapper or update the pattern in the `resolverGeneration.object` config. + */ +export const Span: SpanResolvers = { + id(span) { + return span.spanId; + }, + name(span) { + return span.spanName; + }, + parentId(span) { + return span.parentSpanId; + }, + startTime(span) { + return span.startDate; + }, + endTime(span) { + return span.endDate; + }, +}; diff --git a/packages/services/api/src/modules/operations/resolvers/Target.ts b/packages/services/api/src/modules/operations/resolvers/Target.ts index 21affaa344..03fcf8e938 100644 --- a/packages/services/api/src/modules/operations/resolvers/Target.ts +++ b/packages/services/api/src/modules/operations/resolvers/Target.ts @@ -1,5 +1,10 @@ +import Dataloader from 'dataloader'; +import stableJSONStringify from 'fast-json-stable-stringify'; import { parseDateRangeInput } from '../../../shared/helpers'; +import { ClickHouse, sql } from '../providers/clickhouse-client'; import { OperationsManager } from '../providers/operations-manager'; +import { SqlValue } from '../providers/sql'; +import { Traces } from '../providers/traces'; import type { TargetResolvers } from './../../../__generated__/types'; export const Target: Pick< @@ -10,6 +15,11 @@ export const Target: Pick< | 'requestsOverTime' | 'schemaCoordinateStats' | 'totalRequests' + | 'trace' + | 'traces' + | 'tracesFilterOptions' + | 'tracesStatusBreakdown' + | 'viewerCanAccessTraces' > = { totalRequests: (target, { period }, { injector }) => { return injector.get(OperationsManager).countRequests({ @@ -69,4 +79,194 @@ export const Target: Pick< schemaCoordinate: args.schemaCoordinate, }; }, + traces: async (target, { first, filter, sort, after }, { injector }) => { + return injector.get(Traces).findTracesForTargetId( + target.orgId, + target.id, + first ?? null, + { + period: filter?.period ?? null, + duration: filter?.duration + ? { + min: filter.duration.min ?? null, + max: filter.duration.max ?? null, + } + : null, + traceIds: filter?.traceIds ?? null, + success: filter?.success ?? null, + errorCodes: filter?.errorCodes ?? null, + operationNames: filter?.operationNames ?? null, + operationTypes: filter?.operationTypes?.map(value => value ?? null) ?? null, + clientNames: filter?.clientNames ?? null, + subgraphNames: filter?.subgraphNames ?? null, + httpMethods: filter?.httpMethods ?? null, + httpStatusCodes: filter?.httpStatusCodes ?? null, + httpHosts: filter?.httpHosts ?? null, + httpRoutes: filter?.httpRoutes ?? null, + httpUrls: filter?.httpUrls ?? null, + }, + sort ?? null, + after ?? null, + ); + }, + tracesFilterOptions: async (target, { filter }, { injector }) => { + const ANDs: SqlValue[] = [sql`target_id = ${target.id}`]; + + if (filter?.traceIds?.length) { + ANDs.push(sql`"trace_id" IN (${sql.array(filter.traceIds, 'String')})`); + } + + if (filter?.success?.length) { + const hasSuccess = filter.success.includes(true); + const hasError = filter.success.includes(false); + + if (hasSuccess && !hasError) { + ANDs.push( + sql`"graphql_error_count" = 0`, + sql`substring("http_status_code", 1, 1) IN (${sql.array(['2', '3'], 'String')})`, + ); + } else if (hasError && !hasSuccess) { + ANDs.push( + sql`"graphql_error_count" > 0`, + sql`substring("http_status_code", 1, 1) NOT IN (${sql.array(['2', '3'], 'String')})`, + ); + } + } + + if (filter?.operationNames?.length) { + ANDs.push(sql`"graphql_operation_name" IN (${sql.array(filter.operationNames, 'String')})`); + } + + if (filter?.operationTypes?.length) { + ANDs.push( + sql`"graphql_operation_type" IN (${sql.array( + filter.operationTypes.map(value => (value == null ? '' : value.toLowerCase())), + 'String', + )})`, + ); + } + + if (filter?.clientNames?.length) { + ANDs.push(sql`"client_name" IN (${sql.array(filter.clientNames, 'String')})`); + } + + if (filter?.subgraphNames?.length) { + ANDs.push( + sql`hasAny("subgraph_names", (${sql.array(filter.subgraphNames.flat(), 'String')}))`, + ); + } + + if (filter?.httpStatusCodes?.length) { + ANDs.push( + sql`"http_status_code" IN (${sql.array(filter.httpStatusCodes.map(String), 'UInt16')})`, + ); + } + + if (filter?.httpMethods?.length) { + ANDs.push(sql`"http_method" IN (${sql.array(filter.httpMethods, 'String')})`); + } + + if (filter?.httpHosts?.length) { + ANDs.push(sql`"http_host" IN (${sql.array(filter.httpHosts, 'String')})`); + } + + if (filter?.httpRoutes?.length) { + ANDs.push(sql`"http_route" IN (${sql.array(filter.httpRoutes, 'String')})`); + } + + if (filter?.httpUrls?.length) { + ANDs.push(sql`"http_url" IN (${sql.array(filter.httpUrls, 'String')})`); + } + + const loader = new Dataloader< + { + key: string; + columnExpression: string; + limit: number | null; + arrayJoinColumn: string | null; + }, + { value: string; count: number }[], + string + >( + async inputs => { + const statements: SqlValue[] = []; + + for (const { key, columnExpression, limit, arrayJoinColumn } of inputs) { + statements.push(sql` + SELECT + ${key} AS "key", + toString(${sql.raw(columnExpression)}) AS "value", + count(*) AS "count" + FROM "otel_traces_normalized" + ${sql.raw(arrayJoinColumn ? `ARRAY JOIN ${arrayJoinColumn} AS "value"` : '')} + WHERE ${sql.join(ANDs, ' AND ')} + GROUP BY value + ORDER BY count DESC + ${sql.raw(limit ? `LIMIT ${limit}` : '')} + `); + } + + const results = await injector.get(ClickHouse).query<{ + key: string; + value: string; + count: number; + }>({ + query: sql` + ${sql.join(statements, ' UNION ALL ')} + `, + queryId: 'traces_filter_options', + timeout: 10_000, + }); + + const rowsGroupedByKey = results.data.reduce( + (acc, row) => { + if (!acc[row.key]) { + acc[row.key] = []; + } + acc[row.key].push({ value: row.value, count: row.count }); + return acc; + }, + {} as Record, + ); + + return inputs.map(input => rowsGroupedByKey[input.key] ?? []); + }, + { + cacheKeyFn: stableJSONStringify, + }, + ); + + return { + loader, + }; + }, + trace(target, args, { injector }) { + return injector.get(Traces).findTraceById(target.orgId, target.id, args.traceId); + }, + tracesStatusBreakdown: async (target, { filter }, { injector }) => { + return injector.get(Traces).getTraceStatusBreakdownForTargetId(target.orgId, target.id, { + period: filter?.period ?? null, + duration: filter?.duration + ? { + min: filter.duration.min ?? null, + max: filter.duration.max ?? null, + } + : null, + traceIds: filter?.traceIds ?? null, + success: filter?.success ?? null, + errorCodes: filter?.errorCodes ?? null, + operationNames: filter?.operationNames ?? null, + operationTypes: filter?.operationTypes?.map(value => value ?? null) ?? null, + clientNames: filter?.clientNames ?? null, + subgraphNames: filter?.subgraphNames ?? null, + httpMethods: filter?.httpMethods ?? null, + httpStatusCodes: filter?.httpStatusCodes ?? null, + httpHosts: filter?.httpHosts ?? null, + httpRoutes: filter?.httpRoutes ?? null, + httpUrls: filter?.httpUrls ?? null, + }); + }, + viewerCanAccessTraces: async (target, _, { injector }) => { + return injector.get(Traces).viewerCanAccessTraces(target.orgId); + }, }; diff --git a/packages/services/api/src/modules/operations/resolvers/Trace.ts b/packages/services/api/src/modules/operations/resolvers/Trace.ts new file mode 100644 index 0000000000..9a31a85ebf --- /dev/null +++ b/packages/services/api/src/modules/operations/resolvers/Trace.ts @@ -0,0 +1,49 @@ +import { Traces } from '../providers/traces'; +import type { TraceResolvers } from './../../../__generated__/types'; + +/* + * Note: This object type is generated because "TraceMapper" is declared. This is to ensure runtime safety. + * + * When a mapper is used, it is possible to hit runtime errors in some scenarios: + * - given a field name, the schema type's field type does not match mapper's field type + * - or a schema type's field does not exist in the mapper's fields + * + * If you want to skip this file generation, remove the mapper or update the pattern in the `resolverGeneration.object` config. + */ +export const Trace: TraceResolvers = { + /* Implement Trace resolver logic here */ + clientName(trace) { + return trace.clientName; + }, + clientVersion(trace) { + return trace.clientVersion; + }, + httpStatusCode(trace) { + return trace.httpStatusCode; + }, + id(trace) { + return trace.traceId; + }, + operationName(trace) { + return trace.graphqlOperationName; + }, + operationType(trace) { + return trace.graphqlOperationType; + }, + spans(trace, _arg, { injector }) { + return injector.get(Traces).findSpansForTraceId(trace.traceId, trace.targetId); + }, + subgraphs(trace) { + return trace.subgraphNames; + }, + success(trace) { + return ( + (trace.graphqlErrorCodes?.length ?? 0) === 0 && + trace.graphqlErrorCount === 0 && + (trace.httpStatusCode.startsWith('2') || trace.httpStatusCode.startsWith('3')) + ); + }, + operationHash: async trace => { + return trace.graphqlOperationHash; + }, +}; diff --git a/packages/services/api/src/modules/operations/resolvers/TracesFilterOptions.ts b/packages/services/api/src/modules/operations/resolvers/TracesFilterOptions.ts new file mode 100644 index 0000000000..e2b08909c1 --- /dev/null +++ b/packages/services/api/src/modules/operations/resolvers/TracesFilterOptions.ts @@ -0,0 +1,110 @@ +import type { TracesFilterOptionsResolvers } from './../../../__generated__/types'; + +/* + * Note: This object type is generated because "TracesFilterOptionsMapper" is declared. This is to ensure runtime safety. + * + * When a mapper is used, it is possible to hit runtime errors in some scenarios: + * - given a field name, the schema type's field type does not match mapper's field type + * - or a schema type's field does not exist in the mapper's fields + * + * If you want to skip this file generation, remove the mapper or update the pattern in the `resolverGeneration.object` config. + */ +export const TracesFilterOptions: TracesFilterOptionsResolvers = { + /* Implement TracesFilterOptions resolver logic here */ + httpHost: async ({ loader }, { top }) => { + return loader.load({ + key: 'http_host', + columnExpression: 'http_host', + limit: top ?? 5, + arrayJoinColumn: null, + }); + }, + httpMethod: async ({ loader }, { top }) => { + return loader.load({ + key: 'http_method', + columnExpression: 'http_method', + limit: top ?? 5, + arrayJoinColumn: null, + }); + }, + httpRoute: async ({ loader }, { top }) => { + return loader.load({ + key: 'http_route', + columnExpression: 'http_route', + limit: top ?? 5, + arrayJoinColumn: null, + }); + }, + httpStatusCode: async ({ loader }, { top }) => { + return loader.load({ + key: 'http_status_code', + columnExpression: 'http_status_code', + limit: top ?? 5, + arrayJoinColumn: null, + }); + }, + httpUrl: async ({ loader }, { top }) => { + return loader.load({ + key: 'http_url', + columnExpression: 'http_url', + limit: top ?? 5, + arrayJoinColumn: null, + }); + }, + operationName: async ({ loader }, { top }) => { + return loader.load({ + key: 'graphql_operation_name', + columnExpression: 'graphql_operation_name', + limit: top ?? 5, + arrayJoinColumn: null, + }); + }, + operationType: async ({ loader }) => { + return loader.load({ + key: 'graphql_operation_type', + columnExpression: 'graphql_operation_type', + limit: null, + arrayJoinColumn: null, + }); + }, + subgraphs: async ({ loader }, { top }) => { + return loader.load({ + key: 'subgraph_names', + columnExpression: 'value', + limit: top ?? 5, + arrayJoinColumn: 'subgraph_names', + }); + }, + success: async ({ loader }) => { + return loader + .load({ + key: 'success', + columnExpression: + 'if((toUInt16OrZero(http_status_code) >= 200 AND toUInt16OrZero(http_status_code) < 300), true, false) AND "graphql_error_count" = 0', + limit: null, + arrayJoinColumn: null, + }) + .then(data => + data.map(({ value, count }) => ({ + value: value === 'true' ? true : false, + count, + })), + ); + }, + errorCode: async ({ loader }, { top }) => { + return loader.load({ + key: 'errorCode', + columnExpression: 'value', + limit: top ?? 10, + arrayJoinColumn: 'graphql_error_codes', + }); + }, + clientName: async ({ loader }, { top }) => { + return loader.load({ + key: 'client_name', + columnExpression: 'client_name', + limit: top ?? 10, + arrayJoinColumn: null, + }); + }, +}; diff --git a/packages/services/api/src/modules/organization/lib/organization-access-token-permissions.ts b/packages/services/api/src/modules/organization/lib/organization-access-token-permissions.ts index 01fb31164a..0a352e0075 100644 --- a/packages/services/api/src/modules/organization/lib/organization-access-token-permissions.ts +++ b/packages/services/api/src/modules/organization/lib/organization-access-token-permissions.ts @@ -98,13 +98,18 @@ export const permissionGroups: Array = [ }, { id: 'usage-reporting', - title: 'Usage Reporting', + title: 'Usage Reporting and Tracing', permissions: [ { id: 'usage:report', title: 'Report usage data', description: 'Grant access to report usage data.', }, + { + id: 'traces:report', + title: 'Report OTEL traces', + description: 'Grant access to reporting traces.', + }, ], }, { diff --git a/packages/services/api/src/modules/organization/lib/organization-member-permissions.ts b/packages/services/api/src/modules/organization/lib/organization-member-permissions.ts index 318c522cda..3663909811 100644 --- a/packages/services/api/src/modules/organization/lib/organization-member-permissions.ts +++ b/packages/services/api/src/modules/organization/lib/organization-member-permissions.ts @@ -273,6 +273,7 @@ assertAllRulesAreAssigned([ 'appDeployment:publish', 'appDeployment:retire', 'usage:report', + 'traces:report', ]); /** diff --git a/packages/services/api/src/modules/organization/providers/organization-access-tokens.ts b/packages/services/api/src/modules/organization/providers/organization-access-tokens.ts index 9436b8ad86..af35060a8a 100644 --- a/packages/services/api/src/modules/organization/providers/organization-access-tokens.ts +++ b/packages/services/api/src/modules/organization/providers/organization-access-tokens.ts @@ -18,6 +18,7 @@ import { import { IdTranslator } from '../../shared/providers/id-translator'; import { Logger } from '../../shared/providers/logger'; import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool'; +import { Storage } from '../../shared/providers/storage'; import * as OrganizationAccessKey from '../lib/organization-access-key'; import { assignablePermissions } from '../lib/organization-access-token-permissions'; import { ResourceAssignmentModel } from '../lib/resource-assignment-model'; @@ -92,6 +93,7 @@ export class OrganizationAccessTokens { private idTranslator: IdTranslator, private session: Session, private auditLogs: AuditLogRecorder, + private storage: Storage, logger: Logger, ) { this.logger = logger.child({ @@ -140,9 +142,16 @@ export class OrganizationAccessTokens { args.assignedResources ?? { mode: 'GRANULAR' }, ); + const organization = await this.storage.getOrganization({ organizationId }); + const permissions = Array.from( new Set( - args.permissions.filter(permission => assignablePermissions.has(permission as Permission)), + args.permissions.filter( + permission => + assignablePermissions.has(permission as Permission) && + // can only assign traces report permission if otel tracing feature flag is enabled in organization + (permission === 'traces:report' ? organization.featureFlags.otelTracing : true), + ), ), ); diff --git a/packages/services/api/src/modules/organization/resolvers/Organization.ts b/packages/services/api/src/modules/organization/resolvers/Organization.ts index 96c1395a1f..b44febcf46 100644 --- a/packages/services/api/src/modules/organization/resolvers/Organization.ts +++ b/packages/services/api/src/modules/organization/resolvers/Organization.ts @@ -205,12 +205,22 @@ export const Organization: Pick< return OrganizationMemberPermissions.permissionGroups; }, availableOrganizationAccessTokenPermissionGroups: async (organization, _, { injector }) => { - const permissionGroups = OrganizationAccessTokensPermissions.permissionGroups; + let permissionGroups = OrganizationAccessTokensPermissions.permissionGroups; + const isAppDeploymentsEnabled = injector.get(APP_DEPLOYMENTS_ENABLED) || organization.featureFlags.appDeployments; + if (!isAppDeploymentsEnabled) { - return permissionGroups.filter(p => p.id !== 'app-deployments'); + permissionGroups = permissionGroups.filter(p => p.id !== 'app-deployments'); } + + if (!organization.featureFlags.otelTracing) { + permissionGroups = permissionGroups.map(group => ({ + ...group, + permissions: group.permissions.filter(p => p.id !== 'traces:report'), + })); + } + return permissionGroups; }, accessTokens: async (organization, args, { injector }) => { diff --git a/packages/services/api/src/modules/shared/module.graphql.ts b/packages/services/api/src/modules/shared/module.graphql.ts index eab3c5cf5d..c5cf0a1c06 100644 --- a/packages/services/api/src/modules/shared/module.graphql.ts +++ b/packages/services/api/src/modules/shared/module.graphql.ts @@ -14,6 +14,20 @@ export default gql` scalar DateTime @tag(name: "public") @specifiedBy(url: "https://the-guild.dev/graphql/scalars/docs/scalars/date-time") + + """ + A date-time string at UTC with nano-second precision, such as '2007-12-03T10:15:30.123456Z', is compliant with the extended date-time format outlined in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar. The fractional part of the seconds can range up to nanosecond precision. + + This scalar represents an exact instant on the timeline, such as the precise moment a user account was created, including fractional seconds. + + This scalar ignores leap seconds (thereby assuming that a minute constitutes 59 seconds). In this respect, it diverges from the RFC 3339 profile. + + Where an RFC 3339 compliant date-time string has a time-zone other than UTC, it is shifted to UTC. For example, the date-time string '2016-01-01T14:10:20.500+01:00' is shifted to '2016-01-01T13:10:20.500Z'. + """ + scalar DateTime64 + + scalar JSONObject + scalar Date scalar JSON scalar JSONSchemaObject diff --git a/packages/services/api/src/modules/shared/resolvers/DateTime64.ts b/packages/services/api/src/modules/shared/resolvers/DateTime64.ts new file mode 100644 index 0000000000..402a39f920 --- /dev/null +++ b/packages/services/api/src/modules/shared/resolvers/DateTime64.ts @@ -0,0 +1,144 @@ +/** + * @source https://github.com/graphql-hive/graphql-scalars/blob/8f02889d6fb9d391f86fa761ced271c9bb8d5d6f/src/scalars/iso-date/DateTime.ts#L1 + * + * Most of this code originates from there. The only modifications is to not instantiate a JS DateTime but keep the value as a string in order to retain + * nano second precission. + */ +import { GraphQLScalarType, Kind } from 'graphql'; +import { createGraphQLError } from 'graphql-yoga'; + +const leapYear = (year: number): boolean => { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; +}; + +const validateDate = (datestring: string): boolean => { + const RFC_3339_REGEX = /^(\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01]))$/; + + if (!RFC_3339_REGEX.test(datestring)) { + return false; + } + + // Verify the correct number of days for + // the month contained in the date-string. + const year = Number(datestring.substr(0, 4)); + const month = Number(datestring.substr(5, 2)); + const day = Number(datestring.substr(8, 2)); + + switch (month) { + case 2: // February + if ((leapYear(year) && day > 29) || (!leapYear(year) && day > 28)) { + return false; + } + return true; + case 4: // April + case 6: // June + case 9: // September + case 11: // November + if (day > 30) { + return false; + } + break; + } + + return true; +}; + +const validateDateTime = (dateTimeString: string): boolean => { + dateTimeString = dateTimeString?.toUpperCase(); + const RFC_3339_REGEX = + /^(\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]|60))(\.\d{1,})?(([Z])|([+|-]([01][0-9]|2[0-3]):[0-5][0-9]))$/; + + // Validate the structure of the date-string + if (!RFC_3339_REGEX.test(dateTimeString)) { + return false; + } + // Check if it is a correct date using the javascript Date parse() method. + const time = Date.parse(dateTimeString); + if (time !== time) { + return false; + } + // Split the date-time-string up into the string-date and time-string part. + // and check whether these parts are RFC 3339 compliant. + const index = dateTimeString.indexOf('T'); + const dateString = dateTimeString.substr(0, index); + const timeString = dateTimeString.substr(index + 1); + return validateDate(dateString) && validateTime(timeString); +}; + +const validateTime = (time: string): boolean => { + time = time?.toUpperCase(); + const TIME_REGEX = + /^([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])(\.\d{1,})?(([Z])|([+|-]([01][0-9]|2[0-3]):[0-5][0-9]))$/; + return TIME_REGEX.test(time); +}; + +const validateJSDate = (date: Date): boolean => { + const time = date.getTime(); + return time === time; +}; + +const parseDateTime = (dateTime: string) => dateTime; + +export const DateTime64 = new GraphQLScalarType({ + name: 'DateTime64', + serialize(value) { + if (value instanceof Date) { + if (validateJSDate(value)) { + return value.toISOString(); + } + throw createGraphQLError('DateTime cannot represent an invalid Date instance'); + } else if (typeof value === 'string') { + if (validateDateTime(value)) { + return parseDateTime(value); + } + throw createGraphQLError(`DateTime cannot represent an invalid date-time-string ${value}.`); + } else if (typeof value === 'number') { + try { + return new Date(value).toISOString(); + } catch { + throw createGraphQLError('DateTime cannot represent an invalid Unix timestamp ' + value); + } + } else { + throw createGraphQLError( + 'DateTime cannot be serialized from a non string, ' + + 'non numeric or non Date type ' + + JSON.stringify(value), + ); + } + }, + parseValue(value) { + if (value instanceof Date) { + if (validateJSDate(value)) { + return value.toISOString(); + } + throw createGraphQLError('DateTime cannot represent an invalid Date instance'); + } + if (typeof value === 'string') { + if (validateDateTime(value)) { + return parseDateTime(value); + } + throw createGraphQLError(`DateTime cannot represent an invalid date-time-string ${value}.`); + } + throw createGraphQLError( + `DateTime cannot represent non string or Date type ${JSON.stringify(value)}`, + ); + }, + parseLiteral(ast) { + if (ast.kind !== Kind.STRING) { + throw createGraphQLError( + `DateTime cannot represent non string or Date type ${'value' in ast && ast.value}`, + { + nodes: ast, + }, + ); + } + const { value } = ast; + if (validateDateTime(value)) { + return parseDateTime(value); + } + throw createGraphQLError( + `DateTime cannot represent an invalid date-time-string ${String(value)}.`, + { nodes: ast }, + ); + }, +}); diff --git a/packages/services/api/src/modules/shared/resolvers/JSONObject.ts b/packages/services/api/src/modules/shared/resolvers/JSONObject.ts new file mode 100644 index 0000000000..2c499a6042 --- /dev/null +++ b/packages/services/api/src/modules/shared/resolvers/JSONObject.ts @@ -0,0 +1,7 @@ +import { JSONObjectResolver } from 'graphql-scalars'; + +// `scalar JSON` in `module.graphql.ts` does not have a description +// and it messes up the static analysis +JSONObjectResolver.description = undefined; + +export const JSONObject = JSONObjectResolver; diff --git a/packages/services/api/src/shared/entities.ts b/packages/services/api/src/shared/entities.ts index 12117b15fe..4acd090417 100644 --- a/packages/services/api/src/shared/entities.ts +++ b/packages/services/api/src/shared/entities.ts @@ -190,6 +190,7 @@ export interface Organization { */ forceLegacyCompositionInTargets: string[]; appDeployments: boolean; + otelTracing: boolean; }; zendeskId: string | null; /** ID of the user that owns the organization */ diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index 51716090ab..bf29d4d068 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -25,6 +25,8 @@ import { } from '@hive/api'; import { HivePubSub } from '@hive/api/modules/shared/providers/pub-sub'; import { createRedisClient } from '@hive/api/modules/shared/providers/redis'; +import { TargetsByIdCache } from '@hive/api/modules/target/providers/targets-by-id-cache'; +import { TargetsBySlugCache } from '@hive/api/modules/target/providers/targets-by-slug-cache'; import { createArtifactRequestHandler } from '@hive/cdn-script/artifact-handler'; import { ArtifactStorageReader } from '@hive/cdn-script/artifact-storage-reader'; import { AwsClient } from '@hive/cdn-script/aws'; @@ -65,6 +67,7 @@ import { asyncStorage } from './async-storage'; import { env } from './environment'; import { graphqlHandler } from './graphql-handler'; import { clickHouseElapsedDuration, clickHouseReadDuration } from './metrics'; +import { createOtelAuthEndpoint } from './otel-auth-endpoint'; import { createPublicGraphQLHandler } from './public-graphql-handler'; import { initSupertokens, oidcIdLookup } from './supertokens'; @@ -459,6 +462,10 @@ export async function main() { handler: graphql, }); + const authN = new AuthN({ + strategies: [organizationAccessTokenStrategy], + }); + server.route({ method: ['GET', 'POST'], url: '/graphql-public', @@ -466,9 +473,7 @@ export async function main() { registry, logger: logger as any, hiveUsageConfig: env.hive, - authN: new AuthN({ - strategies: [organizationAccessTokenStrategy], - }), + authN, tracing, }), }); @@ -592,6 +597,13 @@ export async function main() { return; }); + createOtelAuthEndpoint({ + server, + authN, + targetsBySlugCache: registry.injector.get(TargetsBySlugCache), + targetsByIdCache: registry.injector.get(TargetsByIdCache), + }); + if (env.cdn.providers.api !== null) { const s3 = { client: new AwsClient({ diff --git a/packages/services/server/src/otel-auth-endpoint.ts b/packages/services/server/src/otel-auth-endpoint.ts new file mode 100644 index 0000000000..7daa881979 --- /dev/null +++ b/packages/services/server/src/otel-auth-endpoint.ts @@ -0,0 +1,119 @@ +import type { FastifyInstance } from 'fastify'; +import type { AuthN } from '@hive/api/modules/auth/lib/authz'; +import type { TargetsByIdCache } from '@hive/api/modules/target/providers/targets-by-id-cache'; +import type { TargetsBySlugCache } from '@hive/api/modules/target/providers/targets-by-slug-cache'; +import { isUUID } from '@hive/api/shared/is-uuid'; + +export function createOtelAuthEndpoint(args: { + server: FastifyInstance; + authN: AuthN; + targetsByIdCache: TargetsByIdCache; + targetsBySlugCache: TargetsBySlugCache; +}) { + args.server.get('/otel-auth', async (req, reply) => { + const targetRefHeader = req.headers['x-hive-target-ref']; + + const targetRefRaw = Array.isArray(targetRefHeader) ? targetRefHeader[0] : targetRefHeader; + + if (typeof targetRefRaw !== 'string' || targetRefRaw.trim().length === 0) { + await reply.status(400).send({ + message: `Missing required header: 'X-Hive-Target-Ref'. Please provide a valid target reference in the request headers.`, + }); + return; + } + + const targetRefParseResult = parseTargetRef(targetRefRaw); + + if (!targetRefParseResult.ok) { + await reply.status(400).send({ + message: targetRefParseResult.error, + }); + return; + } + + const targetRef = targetRefParseResult.data; + + const session = await args.authN.authenticate({ req, reply }); + + const target = await (targetRef.kind === 'id' + ? args.targetsByIdCache.get(targetRef.targetId) + : args.targetsBySlugCache.get(targetRef)); + + if (!target) { + await reply.status(404).send({ + message: `The specified target does not exist. Verify the target reference and try again.`, + }); + return; + } + + const canReportUsage = await session.canPerformAction({ + organizationId: target.orgId, + action: 'traces:report', + params: { + organizationId: target.orgId, + projectId: target.projectId, + targetId: target.id, + }, + }); + + if (!canReportUsage) { + await reply.status(403).send({ + message: `You do not have permission to send traces for this target.`, + }); + return; + } + + await reply.status(200).send({ + message: 'Authenticated', + targetId: target.id, + }); + return; + }); +} + +// TODO: https://github.com/open-telemetry/opentelemetry-collector/blob/ae0b83b94cc4d4cd90a73a2f390d23c25f848aec/config/confighttp/confighttp.go#L551C4-L551C84 +// swallows the error and returns 401 Unauthorized to the OTel SDK. +const invalidTargetRefError = + 'Invalid slug or ID provided for target reference. ' + + 'Must match target slug "$organization_slug/$project_slug/$target_slug" (e.g. "the-guild/graphql-hive/staging") ' + + 'or UUID (e.g. c8164307-0b42-473e-a8c5-2860bb4beff6).'; + +function parseTargetRef(targetRef: string) { + if (targetRef.includes('/')) { + const parts = targetRef.split('/'); + + if (parts.length !== 3) { + return { + ok: false, + error: invalidTargetRefError, + } as const; + } + + const [organizationSlug, projectSlug, targetSlug] = parts; + + return { + ok: true, + data: { + kind: 'slugs', + organizationSlug, + projectSlug, + targetSlug, + }, + } as const; + } + + if (!isUUID(targetRef)) { + return { + ok: false, + error: invalidTargetRefError, + } as const; + } + + return { + ok: true, + data: { + kind: 'id', + targetId: targetRef, + }, + } as const; +} diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index 9e580b2d98..e859280676 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -4737,6 +4737,8 @@ const FeatureFlagsModel = zod forceLegacyCompositionInTargets: zod.array(zod.string()).default([]), /** whether app deployments are enabled for the given organization */ appDeployments: zod.boolean().default(false), + /** whether otel tracing is enabled for the given organization */ + otelTracing: zod.boolean().default(false), }) .optional() .nullable() @@ -4747,6 +4749,7 @@ const FeatureFlagsModel = zod compareToPreviousComposableVersion: false, forceLegacyCompositionInTargets: [], appDeployments: false, + otelTracing: false, }, ); diff --git a/packages/web/app/package.json b/packages/web/app/package.json index 94985cb798..015a11387e 100644 --- a/packages/web/app/package.json +++ b/packages/web/app/package.json @@ -72,6 +72,7 @@ "@types/crypto-js": "^4.2.2", "@types/dompurify": "3.2.0", "@types/js-cookie": "3.0.6", + "@types/lodash.debounce": "4.0.9", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", "@types/react-highlight-words": "0.20.0", @@ -81,12 +82,14 @@ "@urql/exchange-auth": "2.2.0", "@urql/exchange-graphcache": "7.1.0", "@vitejs/plugin-react": "4.3.4", + "@xyflow/react": "12.4.4", "autoprefixer": "10.4.21", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "0.2.1", "crypto-js": "^4.2.0", "date-fns": "4.1.0", + "date-fns-tz": "3.2.0", "dompurify": "3.2.6", "dotenv": "16.4.7", "echarts": "5.6.0", @@ -101,10 +104,13 @@ "js-cookie": "3.0.5", "json-schema-typed": "8.0.1", "json-schema-yup-transformer": "1.6.12", + "jsurl2": "2.2.0", + "lodash.debounce": "4.0.8", "lucide-react": "0.469.0", "mini-svg-data-uri": "1.4.4", "monaco-editor": "0.50.0", "monaco-themes": "0.4.4", + "query-string": "9.1.1", "react": "18.3.1", "react-day-picker": "8.10.1", "react-dom": "18.3.1", @@ -112,6 +118,7 @@ "react-highlight-words": "0.20.0", "react-hook-form": "7.54.2", "react-icons": "5.4.0", + "react-resizable-panels": "2.1.7", "react-select": "5.9.0", "react-string-replace": "1.1.1", "react-textarea-autosize": "8.5.9", @@ -119,6 +126,7 @@ "react-virtualized-auto-sizer": "1.0.25", "react-virtuoso": "4.12.3", "react-window": "1.8.11", + "recharts": "2.15.1", "regenerator-runtime": "0.14.1", "snarkdown": "2.0.0", "storybook": "8.4.7", diff --git a/packages/web/app/src/components/common/not-found-content.tsx b/packages/web/app/src/components/common/not-found-content.tsx new file mode 100644 index 0000000000..cf4221dc76 --- /dev/null +++ b/packages/web/app/src/components/common/not-found-content.tsx @@ -0,0 +1,18 @@ +import ghost from '../../../public/images/figures/ghost.svg?url'; +import { useRouter } from '@tanstack/react-router'; +import { Button } from '../ui/button'; + +export function NotFoundContent(props: { heading: React.ReactNode; subheading: React.ReactNode }) { + const router = useRouter(); + + return ( +
+ Ghost illustration +

{props.heading}

+

{props.subheading}

+ +
+ ); +} diff --git a/packages/web/app/src/components/layouts/target.tsx b/packages/web/app/src/components/layouts/target.tsx index c138118ab0..b33eaaf9e1 100644 --- a/packages/web/app/src/components/layouts/target.tsx +++ b/packages/web/app/src/components/layouts/target.tsx @@ -1,4 +1,4 @@ -import { ReactElement, ReactNode, useMemo, useState } from 'react'; +import { createContext, ReactElement, ReactNode, useContext, useMemo, useState } from 'react'; import { LinkIcon } from 'lucide-react'; import { useQuery } from 'urql'; import { Button } from '@/components/ui/button'; @@ -39,11 +39,48 @@ export enum Page { Checks = 'checks', History = 'history', Insights = 'insights', + Traces = 'traces', Laboratory = 'laboratory', Apps = 'apps', Settings = 'settings', } +type TargetReference = { + organizationSlug: string; + projectSlug: string; + targetSlug: string; +}; + +const TargetReferenceContext = createContext(undefined); + +type TargetReferenceProviderProps = { + children: ReactNode; + organizationSlug: string; + projectSlug: string; + targetSlug: string; +}; + +export const TargetReferenceProvider = ({ + children, + organizationSlug, + projectSlug, + targetSlug, +}: TargetReferenceProviderProps) => { + return ( + + {children} + + ); +}; + +export const useTargetReference = () => { + const context = useContext(TargetReferenceContext); + if (!context) { + throw new Error('useTargetReference must be used within a TargetReferenceProvider'); + } + return context; +}; + const TargetLayoutQuery = graphql(` query TargetLayoutQuery($organizationSlug: String!, $projectSlug: String!, $targetSlug: String!) { me { @@ -67,6 +104,7 @@ const TargetLayoutQuery = graphql(` viewerCanViewLaboratory viewerCanViewAppDeployments viewerCanAccessSettings + viewerCanAccessTraces latestSchemaVersion { id } @@ -113,7 +151,11 @@ export const TargetLayout = ({ useLastVisitedOrganizationWriter(currentOrganization?.slug); return ( - <> +
@@ -207,6 +249,20 @@ export const TargetLayout = ({ Insights + {currentTarget.viewerCanAccessTraces && ( + + + Traces + + + )} {currentTarget.viewerCanViewAppDeployments && ( )} - + ); }; diff --git a/packages/web/app/src/components/ui/button.tsx b/packages/web/app/src/components/ui/button.tsx index 0849ee1430..62af60dcfc 100644 --- a/packages/web/app/src/components/ui/button.tsx +++ b/packages/web/app/src/components/ui/button.tsx @@ -23,6 +23,7 @@ const buttonVariants = cva( lg: 'h-11 px-8 rounded-md', icon: 'size-10', 'icon-sm': 'size-7', + 'icon-xs': 'size-4', }, }, defaultVariants: { diff --git a/packages/web/app/src/components/ui/chart.tsx b/packages/web/app/src/components/ui/chart.tsx new file mode 100644 index 0000000000..a4457ac829 --- /dev/null +++ b/packages/web/app/src/components/ui/chart.tsx @@ -0,0 +1,326 @@ +'use client'; + +import React from 'react'; +import * as RechartsPrimitive from 'recharts'; +import { cn } from '@/lib/utils'; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: '', dark: '.dark' } as const; + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ); +}; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error('useChart must be used within a '); + } + + return context; +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<'div'> & { + config: ChartConfig; + children: React.ComponentProps['children']; + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`; + + return ( + +
+ + {children} +
+
+ ); +}); +ChartContainer.displayName = 'Chart'; + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color); + + if (!colorConfig.length) { + return null; + } + + return ( +