mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
feat: otel telemetry collection and dashboard (#6796)
Co-authored-by: Kamil Kisiela <kamil.kisiela@gmail.com> Co-authored-by: Dotan Simha <dotansimha@gmail.com> Co-authored-by: Denis Badurina <denis@denelop.com>
This commit is contained in:
parent
03f7066449
commit
4f70fc9555
89 changed files with 13335 additions and 243 deletions
17
configs/gateway.config.ts
Normal file
17
configs/gateway.config.ts
Normal file
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
60
deployment/services/otel-collector.ts
Normal file
60
deployment/services/otel-collector.ts
Normal file
|
|
@ -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<typeof deployOTELCollector>;
|
||||
|
||||
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();
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
30
docker/configs/otel-collector/builder-config.yaml
Normal file
30
docker/configs/otel-collector/builder-config.yaml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
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/console/docker/configs/otel-collector/extension-hiveauth v0.0.0
|
||||
path: ./extension-hiveauth
|
||||
name: hiveauthextension # when using local extensions, package name is required, otherwise you get "missing import path"
|
||||
79
docker/configs/otel-collector/config.yaml
Normal file
79
docker/configs/otel-collector/config.yaml
Normal file
|
|
@ -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
|
||||
28
docker/configs/otel-collector/extension-hiveauth/config.go
Normal file
28
docker/configs/otel-collector/extension-hiveauth/config.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// Copyright The OpenTelemetry Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package hiveauthextension // import "github.com/graphql-hive/console/docker/configs/otel-collector/extension-hiveauth"
|
||||
|
||||
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
|
||||
}
|
||||
7
docker/configs/otel-collector/extension-hiveauth/doc.go
Normal file
7
docker/configs/otel-collector/extension-hiveauth/doc.go
Normal file
|
|
@ -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/console/docker/configs/otel-collector/extension-hiveauth"
|
||||
257
docker/configs/otel-collector/extension-hiveauth/extension.go
Normal file
257
docker/configs/otel-collector/extension-hiveauth/extension.go
Normal file
|
|
@ -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
|
||||
}
|
||||
35
docker/configs/otel-collector/extension-hiveauth/factory.go
Normal file
35
docker/configs/otel-collector/extension-hiveauth/factory.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// Copyright The OpenTelemetry Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package hiveauthextension // import "github.com/graphql-hive/console/docker/configs/otel-collector/extension-hiveauth"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/collector/component"
|
||||
"go.opentelemetry.io/collector/extension"
|
||||
|
||||
"github.com/graphql-hive/console/docker/configs/otel-collector/extension-hiveauth/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))
|
||||
}
|
||||
|
|
@ -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()))
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
52
docker/configs/otel-collector/extension-hiveauth/go.mod
Normal file
52
docker/configs/otel-collector/extension-hiveauth/go.mod
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
module github.com/graphql-hive/console/docker/configs/otel-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
|
||||
133
docker/configs/otel-collector/extension-hiveauth/go.sum
Normal file
133
docker/configs/otel-collector/extension-hiveauth/go.sum
Normal file
|
|
@ -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=
|
||||
|
|
@ -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/console/docker/configs/otel-collector/extension-hiveauth"
|
||||
)
|
||||
|
||||
const (
|
||||
ExtensionStability = component.StabilityLevelBeta
|
||||
)
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
type: hiveauth
|
||||
|
||||
status:
|
||||
class: extension
|
||||
stability:
|
||||
beta: [extension]
|
||||
distributions: [contrib, k8s]
|
||||
codeowners:
|
||||
active: [kamilkisiela]
|
||||
|
||||
tests:
|
||||
config:
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
34
docker/otel-collector.dockerfile
Normal file
34
docker/otel-collector.dockerfile
Normal file
|
|
@ -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"]
|
||||
27
load-tests/otel-traces/README.md
Normal file
27
load-tests/otel-traces/README.md
Normal file
|
|
@ -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="<REPLACE ME>" \
|
||||
HIVE_TARGET_REF="the-guild/hive/dev" \
|
||||
k6 run load-tests/otel-traces/test.ts
|
||||
```
|
||||
7
load-tests/otel-traces/package.json
Normal file
7
load-tests/otel-traces/package.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "loade-test-otel-traces",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/k6": "1.2.0"
|
||||
}
|
||||
}
|
||||
259
load-tests/otel-traces/test.ts
Normal file
259
load-tests/otel-traces/test.ts
Normal file
|
|
@ -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<T>(arr: Array<T>) {
|
||||
return arr[getRandomIndex(arr.length)];
|
||||
}
|
||||
|
||||
const clientNames = new Array(countUniqueClients)
|
||||
.fill(null)
|
||||
.map(() => randomString(randomIntBetween(5, 30)));
|
||||
|
||||
const appVersions = new Map<string, Array<string>>();
|
||||
|
||||
for (const name of clientNames) {
|
||||
const versions = new Array<string>();
|
||||
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<Reference> = [
|
||||
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<string, string>();
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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}\"",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
316
packages/migrations/src/clickhouse-actions/015-otel-trace.ts
Normal file
316
packages/migrations/src/clickhouse-actions/015-otel-trace.ts
Normal file
|
|
@ -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'])
|
||||
)
|
||||
`);
|
||||
};
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import type { ClientStatsValues, OperationStatsValues, PageInfo } from '../../__generated__/types';
|
||||
import type { DateRange } from '../../shared/entities';
|
||||
import type { Span, Trace, TraceBreakdownLoader } from './providers/traces';
|
||||
|
||||
// import { SqlValue } from './providers/sql';
|
||||
|
||||
type Connection<TNode> = {
|
||||
pageInfo: PageInfo;
|
||||
|
|
@ -41,3 +44,8 @@ export interface DurationValuesMapper {
|
|||
p95: number | null;
|
||||
p99: number | null;
|
||||
}
|
||||
|
||||
export type TracesFilterOptionsMapper = TraceBreakdownLoader;
|
||||
|
||||
export type TraceMapper = Trace;
|
||||
export type SpanMapper = Span;
|
||||
|
|
|
|||
|
|
@ -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 a 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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,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');
|
||||
}
|
||||
|
||||
|
|
|
|||
832
packages/services/api/src/modules/operations/providers/traces.ts
Normal file
832
packages/services/api/src/modules/operations/providers/traces.ts
Normal file
|
|
@ -0,0 +1,832 @@
|
|||
import DataLoader from 'dataloader';
|
||||
import stableJSONStringify from 'fast-json-stable-stringify';
|
||||
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<string, Trace | null>(async (traceIds: Array<string>) => {
|
||||
this.logger.debug('looking up traces by id (traceIds=%o)', traceIds);
|
||||
const result = await this.clickHouse.query<unknown>({
|
||||
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</* traceId */ string, Trace>();
|
||||
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<Trace | null> {
|
||||
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<Array<Span>> {
|
||||
this.logger.debug('find spans for trace (traceId=%s)', traceId);
|
||||
const result = await this.clickHouse.query<unknown>({
|
||||
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<typeof PaginatedTraceCursorModel>),
|
||||
).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<unknown>({
|
||||
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<unknown>({
|
||||
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);
|
||||
}
|
||||
|
||||
async getTraceFilterOptions(targetId: string, filter: GraphQLSchema.TracesFilterInput | null) {
|
||||
return new TraceBreakdownLoader(this.logger, this.clickHouse, targetId, filter);
|
||||
}
|
||||
}
|
||||
|
||||
export class TraceBreakdownLoader {
|
||||
private logger: Logger;
|
||||
private conditions: SqlValue[];
|
||||
private 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(this.conditions, ' AND ')}
|
||||
GROUP BY value
|
||||
ORDER BY count DESC
|
||||
${sql.raw(limit ? `LIMIT ${limit}` : '')}
|
||||
`);
|
||||
}
|
||||
|
||||
const results = await this.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<string, { value: string; count: number }[]>,
|
||||
);
|
||||
|
||||
return inputs.map(input => rowsGroupedByKey[input.key] ?? []);
|
||||
},
|
||||
{
|
||||
cacheKeyFn: stableJSONStringify,
|
||||
},
|
||||
);
|
||||
|
||||
constructor(
|
||||
logger: Logger,
|
||||
private clickhouse: ClickHouse,
|
||||
targetId: string,
|
||||
filter: GraphQLSchema.TracesFilterInput | null,
|
||||
) {
|
||||
this.logger = logger.child({ source: 'TraceBreakdownLoader' });
|
||||
|
||||
this.conditions = [sql`target_id = ${targetId}`];
|
||||
|
||||
if (filter?.traceIds?.length) {
|
||||
this.conditions.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) {
|
||||
this.conditions.push(
|
||||
sql`"graphql_error_count" = 0`,
|
||||
sql`substring("http_status_code", 1, 1) IN (${sql.array(['2', '3'], 'String')})`,
|
||||
);
|
||||
} else if (hasError && !hasSuccess) {
|
||||
this.conditions.push(
|
||||
sql`"graphql_error_count" > 0`,
|
||||
sql`substring("http_status_code", 1, 1) NOT IN (${sql.array(['2', '3'], 'String')})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (filter?.operationNames?.length) {
|
||||
this.conditions.push(
|
||||
sql`"graphql_operation_name" IN (${sql.array(filter.operationNames, 'String')})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (filter?.operationTypes?.length) {
|
||||
this.conditions.push(
|
||||
sql`"graphql_operation_type" IN (${sql.array(
|
||||
filter.operationTypes.map(value => (value == null ? '' : value.toLowerCase())),
|
||||
'String',
|
||||
)})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (filter?.clientNames?.length) {
|
||||
this.conditions.push(sql`"client_name" IN (${sql.array(filter.clientNames, 'String')})`);
|
||||
}
|
||||
|
||||
if (filter?.subgraphNames?.length) {
|
||||
this.conditions.push(
|
||||
sql`hasAny("subgraph_names", (${sql.array(filter.subgraphNames.flat(), 'String')}))`,
|
||||
);
|
||||
}
|
||||
|
||||
if (filter?.httpStatusCodes?.length) {
|
||||
this.conditions.push(
|
||||
sql`"http_status_code" IN (${sql.array(filter.httpStatusCodes.map(String), 'UInt16')})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (filter?.httpMethods?.length) {
|
||||
this.conditions.push(sql`"http_method" IN (${sql.array(filter.httpMethods, 'String')})`);
|
||||
}
|
||||
|
||||
if (filter?.httpHosts?.length) {
|
||||
this.conditions.push(sql`"http_host" IN (${sql.array(filter.httpHosts, 'String')})`);
|
||||
}
|
||||
|
||||
if (filter?.httpRoutes?.length) {
|
||||
this.conditions.push(sql`"http_route" IN (${sql.array(filter.httpRoutes, 'String')})`);
|
||||
}
|
||||
|
||||
if (filter?.httpUrls?.length) {
|
||||
this.conditions.push(sql`"http_url" IN (${sql.array(filter.httpUrls, 'String')})`);
|
||||
}
|
||||
}
|
||||
|
||||
httpHost(top: number | null) {
|
||||
return this.loader.load({
|
||||
key: 'http_host',
|
||||
columnExpression: 'http_host',
|
||||
limit: top ?? 5,
|
||||
arrayJoinColumn: null,
|
||||
});
|
||||
}
|
||||
httpMethod(top: number | null) {
|
||||
return this.loader.load({
|
||||
key: 'http_method',
|
||||
columnExpression: 'http_method',
|
||||
limit: top ?? 5,
|
||||
arrayJoinColumn: null,
|
||||
});
|
||||
}
|
||||
httpRoute(top: number | null) {
|
||||
return this.loader.load({
|
||||
key: 'http_route',
|
||||
columnExpression: 'http_route',
|
||||
limit: top ?? 5,
|
||||
arrayJoinColumn: null,
|
||||
});
|
||||
}
|
||||
httpStatusCode(top: number | null) {
|
||||
return this.loader.load({
|
||||
key: 'http_status_code',
|
||||
columnExpression: 'http_status_code',
|
||||
limit: top ?? 5,
|
||||
arrayJoinColumn: null,
|
||||
});
|
||||
}
|
||||
httpUrl(top: number | null) {
|
||||
return this.loader.load({
|
||||
key: 'http_url',
|
||||
columnExpression: 'http_url',
|
||||
limit: top ?? 5,
|
||||
arrayJoinColumn: null,
|
||||
});
|
||||
}
|
||||
operationName(top: number | null) {
|
||||
return this.loader.load({
|
||||
key: 'graphql_operation_name',
|
||||
columnExpression: 'graphql_operation_name',
|
||||
limit: top ?? 5,
|
||||
arrayJoinColumn: null,
|
||||
});
|
||||
}
|
||||
operationType() {
|
||||
return this.loader.load({
|
||||
key: 'graphql_operation_type',
|
||||
columnExpression: 'graphql_operation_type',
|
||||
limit: null,
|
||||
arrayJoinColumn: null,
|
||||
});
|
||||
}
|
||||
subgraphs(top: number | null) {
|
||||
return this.loader.load({
|
||||
key: 'subgraph_names',
|
||||
columnExpression: 'value',
|
||||
limit: top ?? 5,
|
||||
arrayJoinColumn: 'subgraph_names',
|
||||
});
|
||||
}
|
||||
success() {
|
||||
return this.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(top: number | null) {
|
||||
return this.loader.load({
|
||||
key: 'errorCode',
|
||||
columnExpression: 'value',
|
||||
limit: top ?? 10,
|
||||
arrayJoinColumn: 'graphql_error_codes',
|
||||
});
|
||||
}
|
||||
clientName(top: number | null) {
|
||||
return this.loader.load({
|
||||
key: 'client_name',
|
||||
columnExpression: 'client_name',
|
||||
limit: top ?? 10,
|
||||
arrayJoinColumn: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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<typeof TraceModel>;
|
||||
|
||||
const TraceListModel = z.array(TraceModel);
|
||||
|
||||
type TraceFilter = {
|
||||
period: null | GraphQLSchema.DateRangeInput;
|
||||
duration: {
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
} | null;
|
||||
traceIds: ReadonlyArray<string> | null;
|
||||
success: ReadonlyArray<boolean> | null;
|
||||
errorCodes: ReadonlyArray<string> | null;
|
||||
operationNames: ReadonlyArray<string> | null;
|
||||
operationTypes: ReadonlyArray<string | null> | null;
|
||||
clientNames: ReadonlyArray<string> | null;
|
||||
subgraphNames: ReadonlyArray<string> | null;
|
||||
httpStatusCodes: ReadonlyArray<string> | null;
|
||||
httpMethods: ReadonlyArray<string> | null;
|
||||
httpHosts: ReadonlyArray<string> | null;
|
||||
httpRoutes: ReadonlyArray<string> | null;
|
||||
httpUrls: ReadonlyArray<string> | 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<typeof SpanModel>;
|
||||
|
||||
type BucketCandidate = {
|
||||
name: string;
|
||||
seconds: number;
|
||||
};
|
||||
|
||||
const bucketCanditates: Array<BucketCandidate> = [
|
||||
{
|
||||
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(),
|
||||
});
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { parseDateRangeInput } from '../../../shared/helpers';
|
||||
import { OperationsManager } from '../providers/operations-manager';
|
||||
import { Traces } from '../providers/traces';
|
||||
import type { TargetResolvers } from './../../../__generated__/types';
|
||||
|
||||
export const Target: Pick<
|
||||
|
|
@ -10,6 +11,11 @@ export const Target: Pick<
|
|||
| 'requestsOverTime'
|
||||
| 'schemaCoordinateStats'
|
||||
| 'totalRequests'
|
||||
| 'trace'
|
||||
| 'traces'
|
||||
| 'tracesFilterOptions'
|
||||
| 'tracesStatusBreakdown'
|
||||
| 'viewerCanAccessTraces'
|
||||
> = {
|
||||
totalRequests: (target, { period }, { injector }) => {
|
||||
return injector.get(OperationsManager).countRequests({
|
||||
|
|
@ -69,4 +75,66 @@ 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(target, { filter }, { injector }) {
|
||||
return injector.get(Traces).getTraceFilterOptions(target.id, filter ?? null);
|
||||
},
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
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 */
|
||||
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(trace) {
|
||||
return trace.graphqlOperationHash;
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
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(loader, { top }) {
|
||||
return loader.httpHost(top ?? null);
|
||||
},
|
||||
httpMethod(loader, { top }) {
|
||||
return loader.httpMethod(top ?? null);
|
||||
},
|
||||
httpRoute(loader, { top }) {
|
||||
return loader.httpRoute(top ?? null);
|
||||
},
|
||||
httpStatusCode(loader, { top }) {
|
||||
return loader.httpStatusCode(top ?? null);
|
||||
},
|
||||
httpUrl(loader, { top }) {
|
||||
return loader.httpUrl(top ?? null);
|
||||
},
|
||||
operationName(loader, { top }) {
|
||||
return loader.operationName(top ?? null);
|
||||
},
|
||||
operationType(loader) {
|
||||
return loader.operationType();
|
||||
},
|
||||
subgraphs(loader, { top }) {
|
||||
return loader.subgraphs(top ?? null);
|
||||
},
|
||||
success(loader) {
|
||||
return loader.success();
|
||||
},
|
||||
errorCode(loader, { top }) {
|
||||
return loader.errorCode(top ?? null);
|
||||
},
|
||||
clientName(loader, { top }) {
|
||||
return loader.clientName(top ?? null);
|
||||
},
|
||||
};
|
||||
|
|
@ -98,13 +98,18 @@ export const permissionGroups: Array<PermissionGroup> = [
|
|||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -273,6 +273,7 @@ assertAllRulesAreAssigned([
|
|||
'appDeployment:publish',
|
||||
'appDeployment:retire',
|
||||
'usage:report',
|
||||
'traces:report',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<boolean>(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 }) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
144
packages/services/api/src/modules/shared/resolvers/DateTime64.ts
Normal file
144
packages/services/api/src/modules/shared/resolvers/DateTime64.ts
Normal file
|
|
@ -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 },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -190,6 +190,7 @@ export interface Organization {
|
|||
*/
|
||||
forceLegacyCompositionInTargets: string[];
|
||||
appDeployments: boolean;
|
||||
otelTracing: boolean;
|
||||
};
|
||||
zendeskId: string | null;
|
||||
/** ID of the user that owns the organization */
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
119
packages/services/server/src/otel-auth-endpoint.ts
Normal file
119
packages/services/server/src/otel-auth-endpoint.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
18
packages/web/app/src/components/common/not-found-content.tsx
Normal file
18
packages/web/app/src/components/common/not-found-content.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex h-full flex-1 flex-col items-center justify-center gap-2.5 py-6">
|
||||
<img src={ghost} alt="Ghost illustration" width="200" height="200" className="drag-none" />
|
||||
<h2 className="text-xl font-bold">{props.heading}</h2>
|
||||
<h3 className="font-semibold">{props.subheading}</h3>
|
||||
<Button variant="secondary" className="mt-2" onClick={router.history.back}>
|
||||
Go back
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<TargetReference | undefined>(undefined);
|
||||
|
||||
type TargetReferenceProviderProps = {
|
||||
children: ReactNode;
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
targetSlug: string;
|
||||
};
|
||||
|
||||
export const TargetReferenceProvider = ({
|
||||
children,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
targetSlug,
|
||||
}: TargetReferenceProviderProps) => {
|
||||
return (
|
||||
<TargetReferenceContext.Provider value={{ organizationSlug, projectSlug, targetSlug }}>
|
||||
{children}
|
||||
</TargetReferenceContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<>
|
||||
<TargetReferenceProvider
|
||||
organizationSlug={props.organizationSlug}
|
||||
projectSlug={props.projectSlug}
|
||||
targetSlug={props.targetSlug}
|
||||
>
|
||||
<header>
|
||||
<div className="container flex h-[--header-height] items-center justify-between">
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
|
|
@ -207,6 +249,20 @@ export const TargetLayout = ({
|
|||
Insights
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
{currentTarget.viewerCanAccessTraces && (
|
||||
<TabsTrigger variant="menu" value={Page.Traces} asChild>
|
||||
<Link
|
||||
to="/$organizationSlug/$projectSlug/$targetSlug/traces"
|
||||
params={{
|
||||
organizationSlug: props.organizationSlug,
|
||||
projectSlug: props.projectSlug,
|
||||
targetSlug: props.targetSlug,
|
||||
}}
|
||||
>
|
||||
Traces
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{currentTarget.viewerCanViewAppDeployments && (
|
||||
<TabsTrigger variant="menu" value={Page.Apps} asChild>
|
||||
<Link
|
||||
|
|
@ -284,7 +340,7 @@ export const TargetLayout = ({
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</TargetReferenceProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
326
packages/web/app/src/components/ui/chart.tsx
Normal file
326
packages/web/app/src/components/ui/chart.tsx
Normal file
|
|
@ -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<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useChart must be used within a <ChartContainer />');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<'div'> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'];
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
});
|
||||
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 (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join('\n')}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join('\n'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<'div'> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: 'line' | 'dot' | 'dashed';
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = 'dot',
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item.dataKey || item.name || 'value'}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === 'string'
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
|
||||
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== 'dot';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:size-2.5',
|
||||
indicator === 'dot' && 'items-center',
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]',
|
||||
{
|
||||
'h-2.5 w-2.5': indicator === 'dot',
|
||||
'w-1': indicator === 'line',
|
||||
'w-0 border-[1.5px] border-dashed bg-transparent':
|
||||
indicator === 'dashed',
|
||||
'my-0.5': nestLabel && indicator === 'dashed',
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--color-bg': indicatorColor,
|
||||
'--color-border': indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 justify-between leading-none',
|
||||
nestLabel ? 'items-end' : 'items-center',
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
ChartTooltipContent.displayName = 'ChartTooltip';
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<'div'> &
|
||||
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}
|
||||
>(({ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey }, ref) => {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-4',
|
||||
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload.map(item => {
|
||||
const key = `${nameKey || item.dataKey || 'value'}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn('[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:size-3')}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="size-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
ChartLegendContent.displayName = 'ChartLegend';
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (key in payload && typeof payload[key as keyof typeof payload] === 'string') {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
|
||||
) {
|
||||
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { endOfDay, endOfToday, subMonths } from 'date-fns';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { endOfDay, endOfToday, formatDate, subMonths } from 'date-fns';
|
||||
import { CalendarDays } from 'lucide-react';
|
||||
import { DateRange, Matcher } from 'react-day-picker';
|
||||
import { DurationUnit, formatDateToString, parse, units } from '@/lib/date-math';
|
||||
|
|
@ -32,13 +32,6 @@ export interface DateRangePickerProps {
|
|||
validUnits?: DurationUnit[];
|
||||
}
|
||||
|
||||
const formatDate = (date: Date, locale = 'en-us'): string => {
|
||||
return date.toLocaleDateString(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
interface ResolvedDateRange {
|
||||
from: Date;
|
||||
to: Date;
|
||||
|
|
@ -50,8 +43,17 @@ export type Preset = {
|
|||
range: { from: string; to: string };
|
||||
};
|
||||
|
||||
export function buildDateRangeString(range: ResolvedDateRange, locale = 'en-us'): string {
|
||||
return `${formatDate(range.from, locale)} - ${formatDate(range.to, locale)}`;
|
||||
export function buildDateRangeString(range: ResolvedDateRange): string {
|
||||
const fromDate = formatDate(range.from, 'MMM d');
|
||||
const fromTime = formatDate(range.from, 'HH:mm');
|
||||
const toDate = formatDate(range.to, 'MMM d');
|
||||
const toTime = formatDate(range.to, 'HH:mm');
|
||||
|
||||
if (fromDate === toDate) {
|
||||
return `${fromDate}, ${fromTime} - ${toTime}`;
|
||||
}
|
||||
|
||||
return `${fromDate}, ${fromTime} - ${toDate}, ${toTime}`;
|
||||
}
|
||||
|
||||
function resolveRange(rawFrom: string, rawTo: string): ResolvedDateRange | null {
|
||||
|
|
@ -64,16 +66,6 @@ function resolveRange(rawFrom: string, rawTo: string): ResolvedDateRange | null
|
|||
return null;
|
||||
}
|
||||
|
||||
function calculateWeight(preset: Preset): number {
|
||||
const from = parse(preset.range.from);
|
||||
const to = parse(preset.range.to);
|
||||
if (from && to) {
|
||||
const durationInMinutes = Math.round((to.getTime() - from.getTime()) / (1000 * 60));
|
||||
return durationInMinutes;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const presetLast7Days: Preset = {
|
||||
name: 'last7d',
|
||||
label: 'Last 7 days',
|
||||
|
|
@ -88,8 +80,12 @@ export const presetLast1Day: Preset = {
|
|||
|
||||
// Define presets
|
||||
export const availablePresets: Preset[] = [
|
||||
{ name: 'last15m', label: 'Last 15 minutes', range: { from: 'now-15m', to: 'now' } },
|
||||
{ name: 'last30m', label: 'Last 30 minutes', range: { from: 'now-30m', to: 'now' } },
|
||||
{ name: 'last1h', label: 'Last 1 hour', range: { from: 'now-1h', to: 'now' } },
|
||||
{ name: 'last3h', label: 'Last 3 hours', range: { from: 'now-3h', to: 'now' } },
|
||||
{ name: 'last6h', label: 'Last 6 hours', range: { from: 'now-6h', to: 'now' } },
|
||||
{ name: 'last12h', label: 'Last 12 hours', range: { from: 'now-12h', to: 'now' } },
|
||||
presetLast1Day,
|
||||
presetLast7Days,
|
||||
{ name: 'last14d', label: 'Last 14 days', range: { from: 'now-14d', to: 'now' } },
|
||||
|
|
@ -99,7 +95,60 @@ export const availablePresets: Preset[] = [
|
|||
{ name: 'last1y', label: 'Last 1 year', range: { from: 'now-364d', to: 'now' } },
|
||||
];
|
||||
|
||||
function findMatchingPreset(range: Preset['range']): Preset | undefined {
|
||||
function createQuickRangePresets(number: number, validUnits: DurationUnit[]): Preset[] {
|
||||
const presets: Preset[] = [];
|
||||
|
||||
if (validUnits.includes('m')) {
|
||||
presets.push({
|
||||
name: `last${number}min`,
|
||||
label: `Last ${number} minutes`,
|
||||
range: { from: `now-${number}m`, to: 'now' },
|
||||
});
|
||||
}
|
||||
if (validUnits.includes('h')) {
|
||||
presets.push({
|
||||
name: `last${number}h`,
|
||||
label: `Last ${number} hours`,
|
||||
range: { from: `now-${number}h`, to: 'now' },
|
||||
});
|
||||
}
|
||||
if (validUnits.includes('d')) {
|
||||
presets.push({
|
||||
name: `last${number}d`,
|
||||
label: `Last ${number} days`,
|
||||
range: { from: `now-${number}d`, to: 'now' },
|
||||
});
|
||||
}
|
||||
if (validUnits.includes('w')) {
|
||||
presets.push({
|
||||
name: `last${number}w`,
|
||||
label: `Last ${number} weeks`,
|
||||
range: { from: `now-${number}w`, to: 'now' },
|
||||
});
|
||||
}
|
||||
if (validUnits.includes('M')) {
|
||||
presets.push({
|
||||
name: `last${number}M`,
|
||||
label: `Last ${number} months`,
|
||||
range: { from: `now-${number}M`, to: 'now' },
|
||||
});
|
||||
}
|
||||
|
||||
if (validUnits.includes('y')) {
|
||||
presets.push({
|
||||
name: `last${number}y`,
|
||||
label: `Last ${number} years`,
|
||||
range: { from: `now-${number}y`, to: 'now' },
|
||||
});
|
||||
}
|
||||
|
||||
return presets;
|
||||
}
|
||||
|
||||
export function findMatchingPreset(
|
||||
range: Preset['range'],
|
||||
availablePresets: Preset[],
|
||||
): Preset | undefined {
|
||||
return availablePresets.find(preset => {
|
||||
return preset.range.from === range.from && preset.range.to === range.to;
|
||||
});
|
||||
|
|
@ -113,10 +162,10 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
|
|||
? new RegExp(`[0-9]+(${disallowedUnits.join('|')})`)
|
||||
: null;
|
||||
|
||||
let presets = props.presets ?? availablePresets;
|
||||
let staticPresets = props.presets ?? availablePresets;
|
||||
|
||||
if (hasInvalidUnitRegex) {
|
||||
presets = presets.filter(
|
||||
staticPresets = staticPresets.filter(
|
||||
preset =>
|
||||
!hasInvalidUnitRegex.test(preset.range.from) && !hasInvalidUnitRegex.test(preset.range.to),
|
||||
);
|
||||
|
|
@ -138,34 +187,59 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
|
|||
const [showCalendar, setShowCalendar] = useState(false);
|
||||
|
||||
function getInitialPreset() {
|
||||
let preset: Preset | undefined;
|
||||
const fallbackPreset = staticPresets.at(0) ?? null;
|
||||
|
||||
if (
|
||||
props.selectedRange &&
|
||||
!hasInvalidUnitRegex?.test(props.selectedRange.from) &&
|
||||
!hasInvalidUnitRegex?.test(props.selectedRange.to)
|
||||
!props.selectedRange ||
|
||||
hasInvalidUnitRegex?.test(props.selectedRange.from) ||
|
||||
hasInvalidUnitRegex?.test(props.selectedRange.to)
|
||||
) {
|
||||
preset = findMatchingPreset(props.selectedRange);
|
||||
return fallbackPreset;
|
||||
}
|
||||
|
||||
if (preset) {
|
||||
return preset;
|
||||
}
|
||||
// Attempt to find preset from out pre-defined presets first
|
||||
const preset = findMatchingPreset(props.selectedRange, staticPresets);
|
||||
|
||||
const resolvedRange = resolveRange(props.selectedRange.from, props.selectedRange.to);
|
||||
if (resolvedRange) {
|
||||
return {
|
||||
name: `${props.selectedRange.from}_${props.selectedRange.to}`,
|
||||
label: buildDateRangeString(resolvedRange),
|
||||
range: props.selectedRange,
|
||||
};
|
||||
if (preset) {
|
||||
return preset;
|
||||
}
|
||||
|
||||
// attempt to find the preset based on dynamic presets (so we show something like "last x days" instead of 10. September - 12.September for `now-2d`)
|
||||
if (props.selectedRange.from.startsWith('now-')) {
|
||||
const number = parseInt(props.selectedRange.from.replace(/\D/g, ''), 10);
|
||||
if (!Number.isNaN(number)) {
|
||||
const quickRangPresets = createQuickRangePresets(number, validUnits);
|
||||
|
||||
const preset = quickRangPresets.find(
|
||||
preset =>
|
||||
preset.range.from === props.selectedRange?.from &&
|
||||
preset.range.to === props.selectedRange.to,
|
||||
);
|
||||
|
||||
if (preset) {
|
||||
return preset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return presets.at(0) ?? null;
|
||||
// if everything else fails we show an absolute range!
|
||||
|
||||
const resolvedRange = resolveRange(props.selectedRange.from, props.selectedRange.to);
|
||||
if (resolvedRange) {
|
||||
return {
|
||||
name: `${props.selectedRange.from}_${props.selectedRange.to}`,
|
||||
label: buildDateRangeString(resolvedRange),
|
||||
range: props.selectedRange,
|
||||
};
|
||||
}
|
||||
|
||||
return fallbackPreset;
|
||||
}
|
||||
|
||||
const [activePreset, setActivePreset] = useResetState<Preset | null>(getInitialPreset, [
|
||||
props.selectedRange,
|
||||
]);
|
||||
|
||||
const [fromValue, setFromValue] = useState(activePreset?.range.from ?? '');
|
||||
const [toValue, setToValue] = useState(activePreset?.range.to ?? '');
|
||||
const [range, setRange] = useState<DateRange | undefined>(undefined);
|
||||
|
|
@ -202,74 +276,56 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
|
|||
setActivePreset(getInitialPreset());
|
||||
};
|
||||
|
||||
const PresetButton = ({ preset }: { preset: Preset }): JSX.Element => {
|
||||
let isDisabled = false;
|
||||
const PresetButton = useMemo(
|
||||
() =>
|
||||
function PresetButton({ preset }: { preset: Preset }): React.ReactNode {
|
||||
let isDisabled = false;
|
||||
|
||||
if (props.startDate) {
|
||||
const from = parse(preset.range.from);
|
||||
if (from && from.getTime() < props.startDate.getTime()) {
|
||||
isDisabled = true;
|
||||
}
|
||||
}
|
||||
if (props.startDate) {
|
||||
const from = parse(preset.range.from);
|
||||
const time = from?.getTime();
|
||||
const startTime = props.startDate?.getTime();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActivePreset(preset);
|
||||
setFromValue(preset.range.from);
|
||||
setToValue(preset.range.to);
|
||||
setRange(undefined);
|
||||
setShowCalendar(false);
|
||||
setIsOpen(false);
|
||||
setQuickRangeFilter('');
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
className="w-full justify-start text-left"
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
if (
|
||||
!time ||
|
||||
!startTime ||
|
||||
Number.isNaN(time) ||
|
||||
Number.isNaN(startTime) ||
|
||||
time < props.startDate.getTime()
|
||||
) {
|
||||
isDisabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
const [dynamicPresets, setDynamicPresets] = useState<Preset[]>([]);
|
||||
useEffect(() => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActivePreset(preset);
|
||||
setFromValue(preset.range.from);
|
||||
setToValue(preset.range.to);
|
||||
setRange(undefined);
|
||||
setShowCalendar(false);
|
||||
setIsOpen(false);
|
||||
setQuickRangeFilter('');
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
className="w-full justify-start text-left"
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
[props.startDate],
|
||||
);
|
||||
|
||||
const dynamicPresets = useMemo(() => {
|
||||
const number = parseInt(quickRangeFilter.replace(/\D/g, ''), 10);
|
||||
const dynamicPresets: Preset[] = [
|
||||
{
|
||||
name: `last${number}min`,
|
||||
label: `Last ${number} minutes`,
|
||||
range: { from: `now-${number}m`, to: 'now' },
|
||||
},
|
||||
{
|
||||
name: `last${number}h`,
|
||||
label: `Last ${number} hours`,
|
||||
range: { from: `now-${number}h`, to: 'now' },
|
||||
},
|
||||
{
|
||||
name: `last${number}d`,
|
||||
label: `Last ${number} days`,
|
||||
range: { from: `now-${number}d`, to: 'now' },
|
||||
},
|
||||
{
|
||||
name: `last${number}w`,
|
||||
label: `Last ${number} weeks`,
|
||||
range: { from: `now-${number}w`, to: 'now' },
|
||||
},
|
||||
{
|
||||
name: `last${number}M`,
|
||||
label: `Last ${number} months`,
|
||||
range: { from: `now-${number}M`, to: 'now' },
|
||||
},
|
||||
{
|
||||
name: `last${number}y`,
|
||||
label: `Last ${number} years`,
|
||||
range: { from: `now-${number}y`, to: 'now' },
|
||||
},
|
||||
];
|
||||
|
||||
const dynamicPresets = createQuickRangePresets(number, validUnits);
|
||||
|
||||
const uniqueDynamicPresets = dynamicPresets.filter(
|
||||
preset => !presets.some(p => p.name === preset.name),
|
||||
preset => !staticPresets.some(p => p.name === preset.name),
|
||||
);
|
||||
|
||||
const validDynamicPresets = uniqueDynamicPresets.filter(
|
||||
|
|
@ -279,17 +335,11 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
|
|||
);
|
||||
|
||||
if (number > 0 && validDynamicPresets.length > 0) {
|
||||
setDynamicPresets(validDynamicPresets);
|
||||
} else {
|
||||
setDynamicPresets([]);
|
||||
return validDynamicPresets;
|
||||
}
|
||||
}, [quickRangeFilter, validUnits]);
|
||||
presets = [...presets, ...dynamicPresets].sort((a, b) => {
|
||||
const aWeight = calculateWeight(a);
|
||||
const bWeight = calculateWeight(b);
|
||||
|
||||
return aWeight - bWeight;
|
||||
});
|
||||
return [];
|
||||
}, [quickRangeFilter, validUnits]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
|
|
@ -311,25 +361,34 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
|
|||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align={props.align} className="mt-1 flex h-[380px] w-auto p-0">
|
||||
<div className="flex flex-col py-4">
|
||||
<div className="flex flex-col py-2">
|
||||
<div className="flex flex-col items-center justify-end gap-2 lg:flex-row lg:items-start">
|
||||
<div className="flex flex-col gap-1 pl-3">
|
||||
<div className="mb-2 font-bold">Absolute date range</div>
|
||||
<div className="mb-2 text-sm">Absolute date range</div>
|
||||
<div className="space-y-2">
|
||||
<div className="grid w-full max-w-sm items-center gap-1.5">
|
||||
<Label htmlFor="from">From</Label>
|
||||
<Label htmlFor="from" className="text-xs text-gray-400">
|
||||
From
|
||||
</Label>
|
||||
<div className="flex w-full max-w-sm items-center space-x-2">
|
||||
<Input
|
||||
type="text"
|
||||
id="from"
|
||||
value={fromValue}
|
||||
onChange={ev => {
|
||||
setFromValue(ev.target.value);
|
||||
}}
|
||||
/>
|
||||
<Button size="icon" variant="outline" onClick={() => setShowCalendar(true)}>
|
||||
<CalendarDays className="size-4" />
|
||||
</Button>
|
||||
<div className="relative flex w-full">
|
||||
<Input
|
||||
type="text"
|
||||
id="from"
|
||||
value={fromValue}
|
||||
onChange={ev => {
|
||||
setFromValue(ev.target.value);
|
||||
}}
|
||||
className="font-mono"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute right-2 top-1/2 size-6 -translate-y-1/2 px-0"
|
||||
onClick={() => setShowCalendar(true)}
|
||||
>
|
||||
<CalendarDays className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-red-500">
|
||||
{hasInvalidUnitRegex?.test(fromValue) ? (
|
||||
|
|
@ -340,19 +399,28 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
|
|||
</div>
|
||||
</div>
|
||||
<div className="grid w-full max-w-sm items-center gap-1.5">
|
||||
<Label htmlFor="to">To</Label>
|
||||
<Label htmlFor="to" className="text-xs text-gray-400">
|
||||
To
|
||||
</Label>
|
||||
<div className="flex w-full max-w-sm items-center space-x-2">
|
||||
<Input
|
||||
type="text"
|
||||
id="to"
|
||||
value={toValue}
|
||||
onChange={ev => {
|
||||
setToValue(ev.target.value);
|
||||
}}
|
||||
/>
|
||||
<Button size="icon" variant="outline" onClick={() => setShowCalendar(true)}>
|
||||
<CalendarDays className="size-4" />
|
||||
</Button>
|
||||
<div className="relative flex w-full">
|
||||
<Input
|
||||
type="text"
|
||||
id="to"
|
||||
value={toValue}
|
||||
onChange={ev => {
|
||||
setToValue(ev.target.value);
|
||||
}}
|
||||
className="font-mono"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute right-2 top-1/2 size-6 -translate-y-1/2 px-0"
|
||||
onClick={() => setShowCalendar(true)}
|
||||
>
|
||||
<CalendarDays className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-red-500">
|
||||
{hasInvalidUnitRegex?.test(toValue) ? (
|
||||
|
|
@ -374,10 +442,13 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
|
|||
if (resolvedRange) {
|
||||
setActivePreset(
|
||||
() =>
|
||||
findMatchingPreset({
|
||||
from: fromWithoutWhitespace,
|
||||
to: toWithoutWhitespace,
|
||||
}) ?? {
|
||||
findMatchingPreset(
|
||||
{
|
||||
from: fromWithoutWhitespace,
|
||||
to: toWithoutWhitespace,
|
||||
},
|
||||
availablePresets,
|
||||
) ?? {
|
||||
name: `${fromWithoutWhitespace}_${toWithoutWhitespace}`,
|
||||
label: buildDateRangeString(resolvedRange),
|
||||
range: { from: fromWithoutWhitespace, to: toWithoutWhitespace },
|
||||
|
|
@ -418,7 +489,7 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
|
|||
preset.label.toLowerCase().includes(quickRangeFilter.toLowerCase().trim()),
|
||||
)
|
||||
.map(preset => <PresetButton key={preset.name} preset={preset} />)
|
||||
: presets
|
||||
: staticPresets
|
||||
.filter(preset =>
|
||||
preset.label.toLowerCase().includes(quickRangeFilter.toLowerCase().trim()),
|
||||
)
|
||||
|
|
@ -426,7 +497,7 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
|
|||
</div>
|
||||
</div>
|
||||
{showCalendar && (
|
||||
<div className="absolute left-0 top-0 -translate-x-full">
|
||||
<div className="absolute left-0 top-[4px] -translate-x-full">
|
||||
<div className="bg-popover mr-1 rounded-md border p-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
|
|||
|
|
@ -57,10 +57,11 @@ type SubPageLayoutHeaderProps = {
|
|||
children?: ReactNode;
|
||||
subPageTitle?: ReactNode;
|
||||
description?: string | ReactNode;
|
||||
sideContent?: ReactNode;
|
||||
} & HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
const SubPageLayoutHeader = forwardRef<HTMLDivElement, SubPageLayoutHeaderProps>((props, ref) => (
|
||||
<div className="flex flex-row items-center justify-between" ref={ref}>
|
||||
const SubPageLayoutHeader = forwardRef<HTMLDivElement, SubPageLayoutHeaderProps>((props, ref) => {
|
||||
const header = (
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle>{props.subPageTitle}</CardTitle>
|
||||
{typeof props.description === 'string' ? (
|
||||
|
|
@ -69,9 +70,21 @@ const SubPageLayoutHeader = forwardRef<HTMLDivElement, SubPageLayoutHeaderProps>
|
|||
props.description
|
||||
)}
|
||||
</div>
|
||||
<div>{props.children}</div>
|
||||
</div>
|
||||
));
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-row items-center justify-between" ref={ref}>
|
||||
{props.sideContent ? (
|
||||
<div className="flex w-full">
|
||||
{header}
|
||||
{props.sideContent}
|
||||
</div>
|
||||
) : (
|
||||
header
|
||||
)}
|
||||
<div>{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
SubPageLayoutHeader.displayName = 'SubPageLayoutHeader';
|
||||
|
||||
export { PageLayout, NavLayout, PageLayoutContent, SubPageLayout, SubPageLayoutHeader };
|
||||
|
|
|
|||
39
packages/web/app/src/components/ui/resizable.tsx
Normal file
39
packages/web/app/src/components/ui/resizable.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { GripVertical } from 'lucide-react';
|
||||
import * as ResizablePrimitive from 'react-resizable-panels';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
className={cn('flex size-full data-[panel-group-direction=vertical]:flex-col', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
const ResizablePanel = ResizablePrimitive.Panel;
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean;
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
'bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-sm border">
|
||||
<GripVertical className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
);
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
|
|
@ -11,7 +11,8 @@ const ScrollArea = React.forwardRef<
|
|||
className={cn('relative overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="size-full rounded-[inherit]">
|
||||
{/** LOL https://github.com/radix-ui/primitives/issues/2722 */}
|
||||
<ScrollAreaPrimitive.Viewport className="size-full rounded-[inherit] [&>div]:!block">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
|
|
|
|||
|
|
@ -49,14 +49,16 @@ const sheetVariants = cva(
|
|||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
VariantProps<typeof sheetVariants> {
|
||||
noOverlay?: boolean;
|
||||
}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = 'right', className, children, ...props }, ref) => (
|
||||
>(({ side = 'right', className, children, noOverlay, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
{noOverlay ? null : <SheetOverlay />}
|
||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
|
||||
|
|
|
|||
732
packages/web/app/src/components/ui/sidebar.tsx
Normal file
732
packages/web/app/src/components/ui/sidebar.tsx
Normal file
|
|
@ -0,0 +1,732 @@
|
|||
import React from 'react';
|
||||
import { cva, VariantProps } from 'class-variance-authority';
|
||||
import { PanelLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = 'sidebar_state';
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = '16rem';
|
||||
const SIDEBAR_WIDTH_MOBILE = '18rem';
|
||||
const SIDEBAR_WIDTH_ICON = '3rem';
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
|
||||
|
||||
type SidebarContext = {
|
||||
state: 'expanded' | 'collapsed';
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContext | null>(null);
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error('useSidebar must be used within a SidebarProvider.');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const SidebarProvider = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<'div'> & {
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const isMobile = false;
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [internalIsOpen, setInternalIsOpen] = React.useState(defaultOpen);
|
||||
const open = openProp ?? internalIsOpen;
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === 'function' ? value(open) : value;
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState);
|
||||
} else {
|
||||
setInternalIsOpen(openState);
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open],
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open);
|
||||
}, [isMobile, setOpen, setOpenMobile]);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? 'expanded' : 'collapsed';
|
||||
|
||||
const contextValue = React.useMemo<SidebarContext>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': SIDEBAR_WIDTH,
|
||||
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
'group/sidebar-wrapper has-[[data-variant=inset]]:bg-sidebar flex min-h-svh w-full',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarProvider.displayName = 'SidebarProvider';
|
||||
|
||||
const Sidebar = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<'div'> & {
|
||||
side?: 'left' | 'right';
|
||||
variant?: 'sidebar' | 'floating' | 'inset';
|
||||
collapsible?: 'offcanvas' | 'icon' | 'none';
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
side = 'left',
|
||||
variant = 'sidebar',
|
||||
collapsible = 'offcanvas',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
if (collapsible === 'none') {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-sidebar text-sidebar-foreground flex h-full w-[--sidebar-width] flex-col',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-[--sidebar-width] p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<div className="flex size-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="text-sidebar-foreground group peer hidden md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === 'collapsed' ? collapsible : ''}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-svh w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear',
|
||||
'group-data-[collapsible=offcanvas]:w-0',
|
||||
'group-data-[side=right]:rotate-180',
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]'
|
||||
: 'group-data-[collapsible=icon]:w-[--sidebar-width-icon]',
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex',
|
||||
side === 'left'
|
||||
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
||||
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]'
|
||||
: 'group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex size-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
Sidebar.displayName = 'Sidebar';
|
||||
|
||||
const SidebarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof Button>,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, onClick, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
data-sidebar="trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('size-7', className)}
|
||||
onClick={event => {
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeft />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
SidebarTrigger.displayName = 'SidebarTrigger';
|
||||
|
||||
const SidebarRail = React.forwardRef<HTMLButtonElement, React.ComponentProps<'button'>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
data-sidebar="rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex',
|
||||
'[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize',
|
||||
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
|
||||
'group-data-[collapsible=offcanvas]:hover:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
|
||||
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
|
||||
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarRail.displayName = 'SidebarRail';
|
||||
|
||||
const SidebarInset = React.forwardRef<HTMLDivElement, React.ComponentProps<'main'>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<main
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-background relative flex min-h-svh flex-1 flex-col',
|
||||
'peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarInset.displayName = 'SidebarInset';
|
||||
|
||||
const SidebarInput = React.forwardRef<
|
||||
React.ElementRef<typeof Input>,
|
||||
React.ComponentProps<typeof Input>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
data-sidebar="input"
|
||||
className={cn(
|
||||
'bg-background focus-visible:ring-sidebar-ring h-8 w-full shadow-none focus-visible:ring-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarInput.displayName = 'SidebarInput';
|
||||
|
||||
const SidebarHeader = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="header"
|
||||
className={cn('flex flex-col gap-2 p-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarHeader.displayName = 'SidebarHeader';
|
||||
|
||||
const SidebarFooter = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="footer"
|
||||
className={cn('flex flex-col gap-2 p-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarFooter.displayName = 'SidebarFooter';
|
||||
|
||||
const SidebarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof Separator>,
|
||||
React.ComponentProps<typeof Separator>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Separator
|
||||
ref={ref}
|
||||
data-sidebar="separator"
|
||||
className={cn('bg-sidebar-border mx-2 w-auto', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarSeparator.displayName = 'SidebarSeparator';
|
||||
|
||||
const SidebarContent = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarContent.displayName = 'SidebarContent';
|
||||
|
||||
const SidebarGroup = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="group"
|
||||
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarGroup.displayName = 'SidebarGroup';
|
||||
|
||||
const SidebarGroupLabel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<'div'> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'div';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-none transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarGroupLabel.displayName = 'SidebarGroupLabel';
|
||||
|
||||
const SidebarGroupAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<'button'> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-none transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
// Increases the hit area of the button on mobile.
|
||||
'after:absolute after:-inset-2 after:md:hidden',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarGroupAction.displayName = 'SidebarGroupAction';
|
||||
|
||||
const SidebarGroupContent = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="group-content"
|
||||
className={cn('w-full text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
SidebarGroupContent.displayName = 'SidebarGroupContent';
|
||||
|
||||
const SidebarMenu = React.forwardRef<HTMLUListElement, React.ComponentProps<'ul'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
data-sidebar="menu"
|
||||
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
SidebarMenu.displayName = 'SidebarMenu';
|
||||
|
||||
const SidebarMenuItem = React.forwardRef<HTMLLIElement, React.ComponentProps<'li'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
data-sidebar="menu-item"
|
||||
className={cn('group/menu-item relative', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
SidebarMenuItem.displayName = 'SidebarMenuItem';
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||
outline:
|
||||
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
|
||||
},
|
||||
size: {
|
||||
default: 'h-8 text-sm',
|
||||
sm: 'h-7 text-xs',
|
||||
lg: 'h-12 text-sm group-data-[collapsible=icon]:!p-0',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const SidebarMenuButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<'button'> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||
>(
|
||||
(
|
||||
{
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === 'string') {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== 'collapsed' || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarMenuButton.displayName = 'SidebarMenuButton';
|
||||
|
||||
const SidebarMenuAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<'button'> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}
|
||||
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-none transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
// Increases the hit area of the button on mobile.
|
||||
'after:absolute after:-inset-2 after:md:hidden',
|
||||
'peer-data-[size=sm]/menu-button:top-1',
|
||||
'peer-data-[size=default]/menu-button:top-1.5',
|
||||
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
showOnHover &&
|
||||
'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarMenuAction.displayName = 'SidebarMenuAction';
|
||||
|
||||
const SidebarMenuBadge = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums',
|
||||
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
|
||||
'peer-data-[size=sm]/menu-button:top-1',
|
||||
'peer-data-[size=default]/menu-button:top-1.5',
|
||||
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
SidebarMenuBadge.displayName = 'SidebarMenuBadge';
|
||||
|
||||
const SidebarMenuSkeleton = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<'div'> & {
|
||||
showIcon?: boolean;
|
||||
}
|
||||
>(({ className, showIcon = false, ...props }, ref) => {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
|
||||
<Skeleton
|
||||
className="h-4 max-w-[--skeleton-width] flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
'--skeleton-width': width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
SidebarMenuSkeleton.displayName = 'SidebarMenuSkeleton';
|
||||
|
||||
const SidebarMenuSub = React.forwardRef<HTMLUListElement, React.ComponentProps<'ul'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
SidebarMenuSub.displayName = 'SidebarMenuSub';
|
||||
|
||||
const SidebarMenuSubItem = React.forwardRef<HTMLLIElement, React.ComponentProps<'li'>>(
|
||||
({ ...props }, ref) => <li ref={ref} {...props} />,
|
||||
);
|
||||
SidebarMenuSubItem.displayName = 'SidebarMenuSubItem';
|
||||
|
||||
const SidebarMenuSubButton = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentProps<'a'> & {
|
||||
asChild?: boolean;
|
||||
size?: 'sm' | 'md';
|
||||
isActive?: boolean;
|
||||
}
|
||||
>(({ asChild = false, size = 'md', isActive, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'a';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
|
||||
size === 'sm' && 'text-xs',
|
||||
size === 'md' && 'text-sm',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarMenuSubButton.displayName = 'SidebarMenuSubButton';
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
};
|
||||
|
|
@ -1,7 +1,12 @@
|
|||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('bg-primary/10 animate-pulse rounded-md', className)} {...props} />;
|
||||
return (
|
||||
<div
|
||||
className={cn('bg-primary/10 animate-pulse rounded-md align-middle', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
|
|
|
|||
|
|
@ -40,6 +40,18 @@
|
|||
--header-height: 84px;
|
||||
--tabs-navbar-height: 47px;
|
||||
--content-height: calc(100vh - var(--header-height) - var(--tabs-navbar-height));
|
||||
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
|
||||
--chart-1: 173 58% 39%;
|
||||
--chart-2: 12 76% 61%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
|
|
@ -76,6 +88,18 @@
|
|||
--ring: 216 34% 17%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -84,6 +108,10 @@
|
|||
@apply border-border;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply bg-[#030711];
|
||||
}
|
||||
|
||||
body {
|
||||
@apply text-foreground bg-[#030711];
|
||||
font-feature-settings:
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* The original source code was taken from Grafana's date-math.ts file and adjusted for Hive needs.
|
||||
* @source https://github.com/grafana/grafana/blob/411c89012febe13323e4b8aafc8d692f4460e680/packages/grafana-data/src/datetime/datemath.ts#L1C1-L208C2
|
||||
*/
|
||||
import { add, format, formatISO, parse as parseDate, sub, type Duration } from 'date-fns';
|
||||
import { add, format, formatISO, parse as parseDate, parseISO, sub, type Duration } from 'date-fns';
|
||||
import { z } from 'zod';
|
||||
import { UTCDate } from '@date-fns/utc';
|
||||
|
||||
|
|
@ -32,7 +32,7 @@ function unitToDurationKey(unit: DurationUnit): keyof Duration {
|
|||
}
|
||||
}
|
||||
|
||||
const dateStringFormat = 'yyyy-MM-dd';
|
||||
const dateStringFormat = 'yyyy-MM-dd HH:mm';
|
||||
|
||||
function parseDateString(input: string) {
|
||||
try {
|
||||
|
|
@ -46,38 +46,37 @@ export function formatDateToString(date: Date) {
|
|||
return format(date, dateStringFormat);
|
||||
}
|
||||
|
||||
function isValidDateString(input: string) {
|
||||
return input.length === 10 && parseDateString(input) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses different types input to a moment instance. There is a specific formatting language that can be used
|
||||
* if text arg is string. See unit tests for examples.
|
||||
* @param text
|
||||
* @param roundUp See parseDateMath function.
|
||||
* @param timezone Only string 'utc' is acceptable here, for anything else, local timezone is used.
|
||||
* Parse a time iso string or formular into an actual date
|
||||
*/
|
||||
export function parse(text: string, now = new UTCDate()): Date | undefined {
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let mathString = '';
|
||||
if (!text.startsWith('now')) {
|
||||
// Try parsing as yyyy-MM-dd HH:mm
|
||||
const date = parseDateString(text);
|
||||
if (date && !Number.isNaN(date.getTime())) {
|
||||
return date;
|
||||
}
|
||||
|
||||
// Try parsing as ISO
|
||||
const isoDate = parseISO(text);
|
||||
if (isoDate && !Number.isNaN(isoDate.getTime())) {
|
||||
return isoDate;
|
||||
}
|
||||
|
||||
if (text.substring(0, 3) === 'now') {
|
||||
// time = dateTimeForTimeZone(timezone);
|
||||
mathString = text.substring('now'.length);
|
||||
} else if (isValidDateString(text)) {
|
||||
return parseDateString(text);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!mathString.length) {
|
||||
const mathExpression = text.slice('now'.length);
|
||||
if (!mathExpression) {
|
||||
return now;
|
||||
}
|
||||
|
||||
return parseDateMath(mathString, now);
|
||||
// Handle "now" with date math (e.g., "now+1d")
|
||||
return parseDateMath(mathExpression, now);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -16,8 +16,12 @@ import { useRouter } from '@tanstack/react-router';
|
|||
import { useResetState } from './use-reset-state';
|
||||
|
||||
export function useDateRangeController(args: {
|
||||
/** the data retention aka minimum time range. */
|
||||
dataRetentionInDays: number;
|
||||
/** the default preset to pick if no range is provided. */
|
||||
defaultPreset: Preset;
|
||||
/** controlled input range */
|
||||
range?: Preset['range'];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
|
|
@ -25,11 +29,10 @@ export function useDateRangeController(args: {
|
|||
() => subDays(new Date(), args.dataRetentionInDays),
|
||||
[args.dataRetentionInDays],
|
||||
);
|
||||
|
||||
const searchParams = router.latestLocation.search;
|
||||
// const params = new URLSearchParams(urlParameter);
|
||||
const fromRaw = (('from' in searchParams && searchParams.from) ?? '') as string;
|
||||
const toRaw = (('to' in searchParams && searchParams.to) ?? 'now') as string;
|
||||
const fromRaw =
|
||||
args.range?.from ?? ((('from' in searchParams && searchParams.from) ?? '') as string);
|
||||
const toRaw = args.range?.to ?? ((('to' in searchParams && searchParams.to) ?? 'now') as string);
|
||||
|
||||
const [selectedPreset] = useResetState(() => {
|
||||
const preset = availablePresets.find(p => p.range.from === fromRaw && p.range.to === toRaw);
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export const urqlClient = createClient({
|
|||
resolvers: {
|
||||
Target: {
|
||||
appDeployments: relayPagination(),
|
||||
traces: relayPagination(),
|
||||
},
|
||||
AppDeployment: {
|
||||
documents: relayPagination(),
|
||||
|
|
@ -77,6 +78,10 @@ export const urqlClient = createClient({
|
|||
MetadataAttribute: noKey,
|
||||
RateLimit: noKey,
|
||||
DeprecatedSchemaExplorer: noKey,
|
||||
TraceStatusBreakdownBucket: noKey,
|
||||
FilterStringOption: noKey,
|
||||
FilterBooleanOption: noKey,
|
||||
TracesFilterOptions: noKey,
|
||||
},
|
||||
globalIDs: ['SuccessfulSchemaCheck', 'FailedSchemaCheck'],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import ghost from '../../public/images/figures/ghost.svg?url';
|
||||
import { LoaderCircleIcon } from 'lucide-react';
|
||||
import { useClient, useQuery } from 'urql';
|
||||
import { AppFilter } from '@/components/apps/AppFilter';
|
||||
import { NotFoundContent } from '@/components/common/not-found-content';
|
||||
import { Page, TargetLayout } from '@/components/layouts/target';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CardDescription } from '@/components/ui/card';
|
||||
|
|
@ -157,20 +157,10 @@ function TargetAppVersionContent(props: {
|
|||
return (
|
||||
<>
|
||||
<Meta title="App Version Not found" />
|
||||
<div className="flex h-full flex-1 flex-col items-center justify-center gap-2.5 py-6">
|
||||
<img
|
||||
src={ghost}
|
||||
alt="Ghost illustration"
|
||||
width="200"
|
||||
height="200"
|
||||
className="drag-none"
|
||||
/>
|
||||
<h2 className="text-xl font-bold">App Version not found.</h2>
|
||||
<h3 className="font-semibold">This app does not seem to exist anymore.</h3>
|
||||
<Button variant="secondary" className="mt-2" onClick={router.history.back}>
|
||||
Go back
|
||||
</Button>
|
||||
</div>
|
||||
<NotFoundContent
|
||||
heading="App Version not found."
|
||||
subheading="This app does not seem to exist anymore."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
1789
packages/web/app/src/pages/target-trace.tsx
Normal file
1789
packages/web/app/src/pages/target-trace.tsx
Normal file
File diff suppressed because it is too large
Load diff
1406
packages/web/app/src/pages/target-traces.tsx
Normal file
1406
packages/web/app/src/pages/target-traces.tsx
Normal file
File diff suppressed because it is too large
Load diff
677
packages/web/app/src/pages/traces/target-traces-filter.tsx
Normal file
677
packages/web/app/src/pages/traces/target-traces-filter.tsx
Normal file
|
|
@ -0,0 +1,677 @@
|
|||
import {
|
||||
ChangeEventHandler,
|
||||
ComponentPropsWithoutRef,
|
||||
ElementRef,
|
||||
forwardRef,
|
||||
Fragment,
|
||||
InputHTMLAttributes,
|
||||
memo,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { addDays, formatDate, setHours, setMinutes } from 'date-fns';
|
||||
import debounce from 'lodash.debounce';
|
||||
import {
|
||||
CalendarIcon,
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
CircleXIcon,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
} from 'lucide-react';
|
||||
import type { DateRange } from 'react-day-picker';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { findMatchingPreset, Preset } from '@/components/ui/date-range-picker';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarSeparator,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { formatNumber } from '@/lib/hooks';
|
||||
import { useResetState } from '@/lib/hooks/use-reset-state';
|
||||
import { cn } from '@/lib/utils';
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider';
|
||||
import * as dateMath from '../../lib/date-math';
|
||||
|
||||
interface FilterInputProps extends InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
export const FilterInput = forwardRef<HTMLInputElement, FilterInputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export function FilterLocalSearch(props: { value: string; onChange(value: string): void }) {
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
props.onChange(e.target.value);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex w-full max-w-sm items-center space-x-2">
|
||||
<FilterInput
|
||||
type="text"
|
||||
placeholder="Search values"
|
||||
value={props.value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterTitle(props: { children: ReactNode; changes?: number; onReset(): void }) {
|
||||
return (
|
||||
<SidebarGroupLabel
|
||||
asChild
|
||||
className="group/label text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground w-full text-sm"
|
||||
>
|
||||
<CollapsibleTrigger>
|
||||
<ChevronRightIcon className="mr-2 transition-transform group-data-[state=open]/collapsible:rotate-90" />
|
||||
{props.children}
|
||||
{props.changes ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="hover:bg-secondary group ml-auto h-6 w-8 px-1 py-0 text-xs text-gray-500"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
props.onReset();
|
||||
}}
|
||||
asChild
|
||||
>
|
||||
<div>
|
||||
<CircleXIcon className="hidden size-3 group-hover:block" />
|
||||
<span className="block group-hover:hidden">{props.changes}</span>
|
||||
</div>
|
||||
</Button>
|
||||
) : null}
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterContent(props: { children: ReactNode }) {
|
||||
return (
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>{props.children}</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
);
|
||||
}
|
||||
|
||||
export const MultiInputFilter = memo(
|
||||
(props: {
|
||||
name: string;
|
||||
/**
|
||||
* Filter's key for the backend and url state
|
||||
*/
|
||||
key: string;
|
||||
selectedValues: string[];
|
||||
onChange(selectedValues: string[]): void;
|
||||
}) => {
|
||||
const [traceId, setTraceId] = useState('');
|
||||
const handleTraceIdChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTraceId(e.target.value);
|
||||
},
|
||||
[setTraceId],
|
||||
);
|
||||
|
||||
const addTraceId = useCallback(() => {
|
||||
if (!traceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!props.selectedValues.includes(traceId)) {
|
||||
props.onChange(props.selectedValues.concat(traceId));
|
||||
}
|
||||
|
||||
setTraceId('');
|
||||
}, [traceId, setTraceId]);
|
||||
|
||||
return (
|
||||
<Filter name={props.name}>
|
||||
<FilterTitle changes={props.selectedValues.length} onReset={() => props.onChange([])}>
|
||||
{props.name}
|
||||
</FilterTitle>
|
||||
<FilterContent>
|
||||
<form
|
||||
className="mt-4 flex w-full max-w-sm items-center space-x-2"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
addTraceId();
|
||||
}}
|
||||
>
|
||||
<FilterInput
|
||||
type="text"
|
||||
placeholder="Trace ID..."
|
||||
value={traceId}
|
||||
onChange={handleTraceIdChange}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="size-9 p-0"
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
addTraceId();
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
</Button>
|
||||
</form>
|
||||
{props.selectedValues.map(value => (
|
||||
<SidebarMenuButton
|
||||
key={value}
|
||||
onClick={() => props.onChange(props.selectedValues.filter(val => val !== value))}
|
||||
className="group/trace-id hover:bg-sidebar-accent/50"
|
||||
>
|
||||
<div
|
||||
data-active
|
||||
className="text-sidebar-primary-foreground border-sidebar-primary bg-sidebar-primary group-hover/trace-id:border-sidebar-border flex aspect-square size-4 shrink-0 items-center justify-center rounded-sm border group-hover/trace-id:bg-transparent"
|
||||
>
|
||||
<CheckIcon className="block size-3 group-hover/trace-id:hidden" />
|
||||
<MinusIcon className="hidden size-3 group-hover/trace-id:block" />
|
||||
</div>
|
||||
{value}
|
||||
</SidebarMenuButton>
|
||||
))}
|
||||
</FilterContent>
|
||||
</Filter>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const MultiSelectFilter = function MultiSelectFilter<$Value>(props: {
|
||||
name: string;
|
||||
/**
|
||||
* Filter's key for the backend and url state
|
||||
*/
|
||||
key: string;
|
||||
hideSearch?: boolean;
|
||||
options: Array<{
|
||||
/**
|
||||
* How often it occurs
|
||||
*/
|
||||
count: number;
|
||||
/**
|
||||
* What to display
|
||||
*/
|
||||
label: ReactNode;
|
||||
/**
|
||||
* What to use when searching
|
||||
*/
|
||||
searchContent: string;
|
||||
/**
|
||||
* A value to use when the filter is selected
|
||||
*/
|
||||
value: $Value;
|
||||
}>;
|
||||
selectedValues: $Value[];
|
||||
onChange(selectedValues: $Value[]): void;
|
||||
}) {
|
||||
const [searchPhrase, setSearchPhrase] = useState('');
|
||||
const filteredOptions = useMemo(() => {
|
||||
const lowerSearchPhrase = searchPhrase.toLowerCase().trim();
|
||||
|
||||
if (!lowerSearchPhrase) {
|
||||
return props.options;
|
||||
}
|
||||
|
||||
return props.options.filter(option =>
|
||||
option.searchContent.toLowerCase().includes(lowerSearchPhrase),
|
||||
);
|
||||
}, [searchPhrase, props.options]);
|
||||
|
||||
return (
|
||||
<Filter name={props.name}>
|
||||
<FilterTitle changes={props.selectedValues.length} onReset={() => props.onChange([])}>
|
||||
{props.name}
|
||||
</FilterTitle>
|
||||
<FilterContent>
|
||||
{!props.hideSearch && <FilterLocalSearch value={searchPhrase} onChange={setSearchPhrase} />}
|
||||
{filteredOptions.map((option, index) => (
|
||||
<FilterOption
|
||||
key={index}
|
||||
selected={props.selectedValues.includes(option.value)}
|
||||
count={option.count}
|
||||
onClick={() => {
|
||||
if (props.selectedValues.includes(option.value)) {
|
||||
props.onChange(props.selectedValues.filter(val => val !== option.value));
|
||||
} else {
|
||||
props.onChange(props.selectedValues.concat(option.value));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{option.label === '' ? (
|
||||
<span className="text-gray-400">{'<unknown>'}</span>
|
||||
) : (
|
||||
option.label
|
||||
)}
|
||||
</FilterOption>
|
||||
))}
|
||||
</FilterContent>
|
||||
</Filter>
|
||||
);
|
||||
};
|
||||
|
||||
function FilterOption(props: {
|
||||
onClick(): void;
|
||||
selected: boolean;
|
||||
children: ReactNode;
|
||||
count?: number;
|
||||
}) {
|
||||
return (
|
||||
<SidebarMenuButton
|
||||
onClick={props.onClick}
|
||||
className="hover:bg-sidebar-accent/50 flex-row items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<div
|
||||
data-active={props.selected}
|
||||
className="group/filter-item border-sidebar-border text-sidebar-primary-foreground data-[active=true]:border-sidebar-primary data-[active=true]:bg-sidebar-primary flex aspect-square size-4 shrink-0 items-center justify-center rounded-sm border"
|
||||
>
|
||||
<CheckIcon className="hidden size-3 group-data-[active=true]/filter-item:block" />
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
{props.count ? (
|
||||
<Badge variant="secondary" className="rounded-sm px-1 font-mono font-normal">
|
||||
{formatNumber(props.count)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</SidebarMenuButton>
|
||||
);
|
||||
}
|
||||
|
||||
function Filter(props: { name: string; children: ReactNode }) {
|
||||
return (
|
||||
<Fragment key={props.name}>
|
||||
<SidebarGroup key={props.name} className="py-0">
|
||||
<Collapsible className="group/collapsible">{props.children}</Collapsible>
|
||||
</SidebarGroup>
|
||||
<SidebarSeparator className="mx-0" />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const DoubleSlider = forwardRef<
|
||||
ElementRef<typeof SliderPrimitive.Root>,
|
||||
ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative flex w-full touch-none select-none items-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full bg-gray-800">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-gray-400" />
|
||||
</SliderPrimitive.Track>
|
||||
{props.value?.map((_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
key={index}
|
||||
className="block size-4 rounded-full border border-gray-700 bg-gray-800 transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
|
||||
export const DurationFilter = memo(
|
||||
(props: { value: [number, number] | []; onChange(value: [number, number]): void }) => {
|
||||
const minValue = 0;
|
||||
const maxValue = 100_000;
|
||||
const defaultValues: [number, number] = [minValue, maxValue];
|
||||
const [values, setValues] = useState<[number, number]>(
|
||||
props.value.length ? props.value : defaultValues,
|
||||
);
|
||||
|
||||
const handleStateChange = useMemo(
|
||||
() =>
|
||||
debounce((newValues: [number, number]) => {
|
||||
props.onChange(newValues);
|
||||
}, 1000),
|
||||
[props.onChange],
|
||||
);
|
||||
|
||||
const handleSliderChange = useCallback(
|
||||
(newValues: [number, number]) => {
|
||||
handleStateChange(newValues);
|
||||
setValues(newValues);
|
||||
},
|
||||
[handleStateChange, setValues],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => handleStateChange.cancel();
|
||||
}, [handleStateChange]);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(index: number, value: string) => {
|
||||
const numValue = Number.parseInt(value) || minValue;
|
||||
const newValues: [number, number] = [...values];
|
||||
newValues[index] = Math.min(Math.max(numValue, minValue), maxValue);
|
||||
|
||||
handleStateChange(newValues);
|
||||
setValues(newValues);
|
||||
},
|
||||
[handleStateChange, setValues],
|
||||
);
|
||||
|
||||
const handleMinInputChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
|
||||
e => handleInputChange(0, e.target.value),
|
||||
[handleInputChange],
|
||||
);
|
||||
|
||||
const handleMaxInputChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
|
||||
e => handleInputChange(1, e.target.value),
|
||||
[handleInputChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Filter name="Duration">
|
||||
<FilterTitle
|
||||
changes={values[0] === minValue && values[1] === maxValue ? 0 : 1}
|
||||
onReset={() => props.onChange(defaultValues)}
|
||||
>
|
||||
Duration
|
||||
</FilterTitle>
|
||||
<FilterContent>
|
||||
<div className="space-y-6 p-2">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<label className="font-mono text-xs text-zinc-400">MIN</label>
|
||||
<div className="relative">
|
||||
<FilterInput
|
||||
type="number"
|
||||
value={values[0]}
|
||||
onChange={handleMinInputChange}
|
||||
className="h-7 border-zinc-800 bg-transparent px-2 pr-8 font-mono text-white"
|
||||
/>
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 font-mono text-xs text-zinc-400">
|
||||
ms
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="font-mono text-xs text-zinc-400">MAX</label>
|
||||
<div className="relative">
|
||||
<FilterInput
|
||||
type="number"
|
||||
value={values[1]}
|
||||
onChange={handleMaxInputChange}
|
||||
className="h-7 border-gray-800 bg-transparent px-2 pr-8 font-mono text-white"
|
||||
/>
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 font-mono text-xs text-gray-400">
|
||||
ms
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DoubleSlider
|
||||
defaultValue={defaultValues}
|
||||
max={maxValue}
|
||||
min={minValue}
|
||||
step={1}
|
||||
value={values}
|
||||
onValueChange={handleSliderChange}
|
||||
className="[&_[role=slider]]:size-4"
|
||||
/>
|
||||
</div>
|
||||
</FilterContent>
|
||||
</Filter>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const availableTimelineFilterPresets: Array<Preset> = [
|
||||
{ name: 'last5m', label: 'Last 5 minutes', range: { from: 'now-5m', to: 'now' } },
|
||||
{ name: 'last1hour', label: 'Last 1 hour', range: { from: 'now-1h', to: 'now' } },
|
||||
{ name: 'last3hours', label: 'Last 3 hours', range: { from: 'now-3h', to: 'now' } },
|
||||
{ name: 'last12hours', label: 'Last 12 hours', range: { from: 'now-12h', to: 'now' } },
|
||||
{ name: 'last24hours', label: 'Last 24 hours', range: { from: 'now-24h', to: 'now' } },
|
||||
];
|
||||
|
||||
export const TimelineFilter = memo(
|
||||
(props: { value: [string, string] | []; onChange(value: [string, string] | []): void }) => {
|
||||
const selectedPreset = useMemo<Preset | null>(() => {
|
||||
if (!props.value.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
findMatchingPreset(
|
||||
{
|
||||
from: props.value[0],
|
||||
to: props.value[1],
|
||||
},
|
||||
availableTimelineFilterPresets,
|
||||
) ?? {
|
||||
name: 'custom',
|
||||
label: 'Custom',
|
||||
range: {
|
||||
from: props.value[0],
|
||||
to: props.value[1],
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [props.value]);
|
||||
|
||||
const [isRangeSelectorPopupOpen, setIsRangeSelectorPopupOpen] = useState(false);
|
||||
|
||||
const [dateRange, setDateRange] = useResetState<DateRange | undefined>(
|
||||
() =>
|
||||
selectedPreset?.name === 'custom'
|
||||
? {
|
||||
from: new Date(selectedPreset.range.from),
|
||||
to: new Date(selectedPreset.range.to),
|
||||
}
|
||||
: {
|
||||
from: addDays(new Date(), -3),
|
||||
to: new Date(),
|
||||
},
|
||||
[props.value[0], props.value[1], isRangeSelectorPopupOpen],
|
||||
);
|
||||
|
||||
const formatted = useMemo(() => {
|
||||
if (!dateRange?.from || !dateRange.to || selectedPreset?.name !== 'custom') {
|
||||
return 'Select time period';
|
||||
}
|
||||
|
||||
const fromDate = formatDate(dateRange.from, 'MMM d');
|
||||
const fromTime = formatDate(dateRange.from, 'HH:mm');
|
||||
const toDate = formatDate(dateRange.to, 'MMM d');
|
||||
const toTime = formatDate(dateRange.to, 'HH:mm');
|
||||
|
||||
if (fromDate === toDate) {
|
||||
return `${fromDate}, ${fromTime} - ${toTime}`;
|
||||
}
|
||||
|
||||
return `${fromDate}, ${fromTime} - ${toDate}, ${toTime}`;
|
||||
}, [dateRange, selectedPreset]);
|
||||
|
||||
return (
|
||||
<Filter name="Timeline">
|
||||
<FilterTitle changes={props.value.length ? 1 : 0} onReset={() => props.onChange([])}>
|
||||
Timeline
|
||||
</FilterTitle>
|
||||
<FilterContent>
|
||||
<div className="space-y-2 p-2">
|
||||
<Select
|
||||
// The key is needed to reset the select state to show the SelectTrigger after resetting the filters.
|
||||
key={`${props.value.at(0)}_${props.value.at(1)}`}
|
||||
value={selectedPreset?.name ?? undefined}
|
||||
onValueChange={value => {
|
||||
if (value === 'custom') {
|
||||
const preset = selectedPreset ?? availableTimelineFilterPresets[0];
|
||||
props.onChange([
|
||||
dateMath.parse(preset.range.from)?.toISOString() ?? new Date().toISOString(),
|
||||
dateMath.parse(preset.range.to)?.toISOString() ?? new Date().toISOString(),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
const preset = availableTimelineFilterPresets.find(preset => preset.name === value);
|
||||
if (preset) {
|
||||
props.onChange([preset.range.from, preset.range.to]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="bg-background w-full">
|
||||
<SelectValue placeholder="Select time period" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTimelineFilterPresets.map(preset => (
|
||||
<SelectItem value={preset.name} key={preset.name}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedPreset?.name === 'custom' ? (
|
||||
<>
|
||||
<Popover
|
||||
open={isRangeSelectorPopupOpen}
|
||||
onOpenChange={isOpen => {
|
||||
setIsRangeSelectorPopupOpen(isOpen);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start px-2 text-left">
|
||||
<CalendarIcon className="mr-2 size-4" />{' '}
|
||||
<span className="text-xs">{formatted}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="center">
|
||||
<Calendar
|
||||
initialFocus
|
||||
mode="range"
|
||||
defaultMonth={dateRange?.from}
|
||||
selected={dateRange}
|
||||
onSelect={setDateRange}
|
||||
numberOfMonths={1}
|
||||
className="p-2 pb-0"
|
||||
/>
|
||||
<div className="border-border mt-4 space-y-2 border-t p-2">
|
||||
<div>
|
||||
<Label className="text-sm font-normal text-gray-500">Start</Label>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Input
|
||||
className="h-8 w-[152px] py-0"
|
||||
value={dateRange?.from ? formatDate(dateRange.from, 'yyyy-MM-dd') : ''}
|
||||
/>
|
||||
<Input
|
||||
className="h-8 w-16 py-0"
|
||||
value={dateRange?.from ? formatDate(dateRange.from, 'HH:mm') : ''}
|
||||
type="time"
|
||||
min="00:00"
|
||||
max="23:59"
|
||||
onChange={ev => {
|
||||
setDateRange(range =>
|
||||
range
|
||||
? {
|
||||
...range,
|
||||
from: setMinutes(
|
||||
setHours(
|
||||
range.from ?? new Date(),
|
||||
parseInt(ev.target.value.substr(0, 2)),
|
||||
),
|
||||
parseInt(ev.target.value.substr(3, 5)),
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm font-normal text-gray-500">End</Label>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Input
|
||||
className="h-8 w-[152px] py-0"
|
||||
value={dateRange?.to ? formatDate(dateRange.to, 'yyyy-MM-dd') : ''}
|
||||
/>
|
||||
<Input
|
||||
className="h-8 w-16 py-0"
|
||||
value={dateRange?.to ? formatDate(dateRange.to, 'HH:mm') : ''}
|
||||
type="time"
|
||||
min="00:00"
|
||||
max="23:59"
|
||||
onChange={ev => {
|
||||
setDateRange(range =>
|
||||
range
|
||||
? {
|
||||
...range,
|
||||
to: setMinutes(
|
||||
setHours(
|
||||
range.to ?? new Date(),
|
||||
parseInt(ev.target.value.substr(0, 2)),
|
||||
),
|
||||
parseInt(ev.target.value.substr(3, 5)),
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
if (!dateRange?.from || !dateRange.to) {
|
||||
return;
|
||||
}
|
||||
props.onChange([
|
||||
dateRange.from.toISOString(),
|
||||
dateRange.to.toISOString(),
|
||||
]);
|
||||
setIsRangeSelectorPopupOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="relative">
|
||||
Apply
|
||||
<span className="absolute top-[4px] ml-2 text-xs">↵</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</FilterContent>
|
||||
</Filter>
|
||||
);
|
||||
},
|
||||
);
|
||||
49
packages/web/app/src/pages/traces/target-traces-width.tsx
Normal file
49
packages/web/app/src/pages/traces/target-traces-width.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { createContext, FC, ReactNode, useCallback, useContext, useRef, useState } from 'react';
|
||||
|
||||
interface WidthSyncContextType {
|
||||
width: number;
|
||||
updateWidth: (newWidth: number) => void;
|
||||
}
|
||||
|
||||
const WidthSyncContext = createContext<WidthSyncContextType | null>(null);
|
||||
|
||||
interface WidthSyncProviderProps {
|
||||
children: ReactNode;
|
||||
defaultWidth: number;
|
||||
}
|
||||
|
||||
export const WidthSyncProvider: FC<WidthSyncProviderProps> = props => {
|
||||
const [width, setWidth] = useState<number>(props.defaultWidth);
|
||||
// Throttle state to avoid too many updates during slider dragging
|
||||
const throttleTimerRef = useRef<number | null>(null);
|
||||
|
||||
// Ensure width stays within bounds and apply to all registered components
|
||||
const handleSetWidth = useCallback((newWidth: number) => {
|
||||
// Clear any existing throttle timer
|
||||
if (throttleTimerRef.current !== null) {
|
||||
window.cancelAnimationFrame(throttleTimerRef.current);
|
||||
}
|
||||
|
||||
// Use requestAnimationFrame for smoother updates
|
||||
throttleTimerRef.current = window.requestAnimationFrame(() => {
|
||||
const boundedWidth = newWidth;
|
||||
setWidth(boundedWidth);
|
||||
|
||||
throttleTimerRef.current = null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<WidthSyncContext.Provider value={{ width, updateWidth: handleSetWidth }}>
|
||||
{props.children}
|
||||
</WidthSyncContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useWidthSync = () => {
|
||||
const context = useContext(WidthSyncContext);
|
||||
if (!context) {
|
||||
throw new Error('useWidthSync must be used within a WidthSyncProvider');
|
||||
}
|
||||
return [context.width, context.updateWidth] as const;
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { lazy, useCallback, useEffect } from 'react';
|
||||
import { lazy, useCallback, useEffect, useMemo } from 'react';
|
||||
import { parse as jsUrlParse, stringify as jsUrlStringify } from 'jsurl2';
|
||||
import { Helmet, HelmetProvider } from 'react-helmet-async';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import SuperTokens, { SuperTokensWrapper } from 'supertokens-auth-react';
|
||||
|
|
@ -19,6 +20,8 @@ import {
|
|||
createRouter,
|
||||
Navigate,
|
||||
Outlet,
|
||||
parseSearchWith,
|
||||
stringifySearchWith,
|
||||
useNavigate,
|
||||
} from '@tanstack/react-router';
|
||||
import { ErrorComponent } from './components/error';
|
||||
|
|
@ -74,6 +77,13 @@ import { TargetInsightsCoordinatePage } from './pages/target-insights-coordinate
|
|||
import { TargetInsightsOperationPage } from './pages/target-insights-operation';
|
||||
import { TargetLaboratoryPage } from './pages/target-laboratory';
|
||||
import { TargetSettingsPage, TargetSettingsPageEnum } from './pages/target-settings';
|
||||
import { TargetTracePage } from './pages/target-trace';
|
||||
import {
|
||||
FilterState,
|
||||
TargetTracesFilterState,
|
||||
TargetTracesPage,
|
||||
TargetTracesSort,
|
||||
} from './pages/target-traces';
|
||||
|
||||
SuperTokens.init(frontendConfig());
|
||||
if (env.sentry) {
|
||||
|
|
@ -646,6 +656,85 @@ const targetInsightsRoute = createRoute({
|
|||
},
|
||||
});
|
||||
|
||||
const TargetTracesRouteSearch = z.object({
|
||||
filter: TargetTracesFilterState.optional(),
|
||||
sort: TargetTracesSort.shape.optional(),
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
});
|
||||
|
||||
const targetTracesRoute = createRoute({
|
||||
getParentRoute: () => targetRoute,
|
||||
path: 'traces',
|
||||
validateSearch: TargetTracesRouteSearch.parse,
|
||||
component: function TargetTracesRoute() {
|
||||
const { organizationSlug, projectSlug, targetSlug } = targetTracesRoute.useParams();
|
||||
const {
|
||||
filter = {
|
||||
'graphql.client': [],
|
||||
'graphql.errorCode': [],
|
||||
'graphql.kind': [],
|
||||
'graphql.operation': [],
|
||||
'graphql.status': [],
|
||||
'graphql.subgraph': [],
|
||||
'http.host': [],
|
||||
'http.method': [],
|
||||
'http.route': [],
|
||||
'http.status': [],
|
||||
'http.url': [],
|
||||
'trace.id': [],
|
||||
duration: [],
|
||||
} satisfies FilterState,
|
||||
sort = {
|
||||
id: 'timestamp',
|
||||
desc: true,
|
||||
},
|
||||
from,
|
||||
to,
|
||||
} = targetTracesRoute.useSearch();
|
||||
|
||||
const range = useMemo(() => (from && to ? { from, to } : null), [from, to]);
|
||||
|
||||
return (
|
||||
<TargetTracesPage
|
||||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
targetSlug={targetSlug}
|
||||
sorting={sort}
|
||||
filter={filter}
|
||||
range={range}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const TargetTraceRouteSearchModel = z.object({
|
||||
activeSpanId: z.string().optional(),
|
||||
activeSpanTab: z.string().optional(),
|
||||
});
|
||||
|
||||
const targetTraceRoute = createRoute({
|
||||
getParentRoute: () => targetRoute,
|
||||
validateSearch(search) {
|
||||
return TargetTraceRouteSearchModel.parse(search);
|
||||
},
|
||||
path: 'trace/$traceId',
|
||||
component: function TargetTraceRoute() {
|
||||
const { organizationSlug, projectSlug, targetSlug, traceId } = targetTraceRoute.useParams();
|
||||
const { activeSpanId, activeSpanTab } = targetTraceRoute.useSearch();
|
||||
return (
|
||||
<TargetTracePage
|
||||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
targetSlug={targetSlug}
|
||||
traceId={traceId}
|
||||
activeSpanId={activeSpanId ?? null}
|
||||
activeSpanTab={activeSpanTab ?? null}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const targetInsightsCoordinateRoute = createRoute({
|
||||
getParentRoute: () => targetRoute,
|
||||
path: 'insights/schema-coordinate/$coordinate',
|
||||
|
|
@ -876,6 +965,8 @@ const routeTree = root.addChildren([
|
|||
targetLaboratoryRoute,
|
||||
targetHistoryRoute.addChildren([targetHistoryVersionRoute]),
|
||||
targetInsightsRoute,
|
||||
targetTraceRoute,
|
||||
targetTracesRoute,
|
||||
targetInsightsCoordinateRoute,
|
||||
targetInsightsClientRoute,
|
||||
targetInsightsOperationsRoute,
|
||||
|
|
@ -890,7 +981,21 @@ const routeTree = root.addChildren([
|
|||
]),
|
||||
]);
|
||||
|
||||
export const router = createRouter({ routeTree });
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
parseSearch: parseSearchWith(str => {
|
||||
if (window.location.pathname.endsWith('/traces')) {
|
||||
return jsUrlParse(str);
|
||||
}
|
||||
return JSON.parse(str);
|
||||
}),
|
||||
stringifySearch: stringifySearchWith(search => {
|
||||
if (window.location.pathname.endsWith('/traces')) {
|
||||
return jsUrlStringify(search);
|
||||
}
|
||||
return JSON.stringify(search);
|
||||
}),
|
||||
});
|
||||
|
||||
router.history.subscribe(() => {
|
||||
gtag.pageview(router.history.location.href);
|
||||
|
|
|
|||
|
|
@ -75,7 +75,6 @@ module.exports = {
|
|||
900: '#005b43',
|
||||
},
|
||||
cyan: '#0acccc',
|
||||
purple: '#5f2eea',
|
||||
blue: colors.sky,
|
||||
gray: colors.stone,
|
||||
magenta: '#f11197',
|
||||
|
|
@ -91,6 +90,8 @@ module.exports = {
|
|||
800: '#926e26',
|
||||
900: '#785a1f',
|
||||
},
|
||||
zinc: colors.zinc,
|
||||
purple: colors.purple,
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
|
|
@ -127,11 +128,22 @@ module.exports = {
|
|||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: 'hsl(var(--sidebar-background))',
|
||||
foreground: 'hsl(var(--sidebar-foreground))',
|
||||
primary: 'hsl(var(--sidebar-primary))',
|
||||
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
|
||||
accent: 'hsl(var(--sidebar-accent))',
|
||||
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
|
||||
border: 'hsl(var(--sidebar-border))',
|
||||
ring: 'hsl(var(--sidebar-ring))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
xs: 'calc(var(--radius) - 6px)',
|
||||
},
|
||||
ringColor: theme => ({
|
||||
DEFAULT: theme('colors.orange.500/75'),
|
||||
|
|
|
|||
|
|
@ -1,13 +1,28 @@
|
|||
import { resolve } from 'node:path';
|
||||
import type { UserConfig } from 'vite';
|
||||
import type { Plugin, UserConfig } from 'vite';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
const __dirname = new URL('.', import.meta.url).pathname;
|
||||
|
||||
// Add react-scan in local development mode
|
||||
const reactScanPlugin: Plugin = {
|
||||
name: 'react-scan',
|
||||
transformIndexHtml(html, ctx) {
|
||||
if (ctx.server?.config.command === 'serve') {
|
||||
return html.replace(
|
||||
'<head>',
|
||||
'<head><script src="https://unpkg.com/react-scan/dist/auto.global.js"></script>',
|
||||
);
|
||||
}
|
||||
|
||||
return html;
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
root: __dirname,
|
||||
plugins: [tsconfigPaths(), react()],
|
||||
plugins: [tsconfigPaths(), react(), reactScanPlugin],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
|
|
|
|||
312
pnpm-lock.yaml
312
pnpm-lock.yaml
|
|
@ -376,6 +376,12 @@ importers:
|
|||
specifier: 3.25.76
|
||||
version: 3.25.76
|
||||
|
||||
load-tests/otel-traces:
|
||||
devDependencies:
|
||||
'@types/k6':
|
||||
specifier: 1.2.0
|
||||
version: 1.2.0
|
||||
|
||||
packages/libraries/apollo:
|
||||
dependencies:
|
||||
'@graphql-hive/core':
|
||||
|
|
@ -1415,7 +1421,7 @@ importers:
|
|||
devDependencies:
|
||||
'@graphql-inspector/core':
|
||||
specifier: 5.1.0-alpha-20231208113249-34700c8a
|
||||
version: 5.1.0-alpha-20231208113249-34700c8a(graphql@16.9.0)
|
||||
version: 5.1.0-alpha-20231208113249-34700c8a(graphql@16.11.0)
|
||||
'@hive/service-common':
|
||||
specifier: workspace:*
|
||||
version: link:../service-common
|
||||
|
|
@ -1851,6 +1857,9 @@ importers:
|
|||
'@types/js-cookie':
|
||||
specifier: 3.0.6
|
||||
version: 3.0.6
|
||||
'@types/lodash.debounce':
|
||||
specifier: 4.0.9
|
||||
version: 4.0.9
|
||||
'@types/react':
|
||||
specifier: 18.3.18
|
||||
version: 18.3.18
|
||||
|
|
@ -1878,6 +1887,9 @@ importers:
|
|||
'@vitejs/plugin-react':
|
||||
specifier: 4.3.4
|
||||
version: 4.3.4(vite@7.1.5(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0))
|
||||
'@xyflow/react':
|
||||
specifier: 12.4.4
|
||||
version: 12.4.4(@types/react@18.3.18)(immer@10.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
autoprefixer:
|
||||
specifier: 10.4.21
|
||||
version: 10.4.21(postcss@8.5.6)
|
||||
|
|
@ -1896,6 +1908,9 @@ importers:
|
|||
date-fns:
|
||||
specifier: 4.1.0
|
||||
version: 4.1.0
|
||||
date-fns-tz:
|
||||
specifier: 3.2.0
|
||||
version: 3.2.0(date-fns@4.1.0)
|
||||
dompurify:
|
||||
specifier: 3.2.6
|
||||
version: 3.2.6
|
||||
|
|
@ -1938,6 +1953,12 @@ importers:
|
|||
json-schema-yup-transformer:
|
||||
specifier: 1.6.12
|
||||
version: 1.6.12
|
||||
jsurl2:
|
||||
specifier: 2.2.0
|
||||
version: 2.2.0
|
||||
lodash.debounce:
|
||||
specifier: 4.0.8
|
||||
version: 4.0.8
|
||||
lucide-react:
|
||||
specifier: 0.469.0
|
||||
version: 0.469.0(react@18.3.1)
|
||||
|
|
@ -1950,6 +1971,9 @@ importers:
|
|||
monaco-themes:
|
||||
specifier: 0.4.4
|
||||
version: 0.4.4
|
||||
query-string:
|
||||
specifier: 9.1.1
|
||||
version: 9.1.1
|
||||
react:
|
||||
specifier: 18.3.1
|
||||
version: 18.3.1
|
||||
|
|
@ -1971,6 +1995,9 @@ importers:
|
|||
react-icons:
|
||||
specifier: 5.4.0
|
||||
version: 5.4.0(react@18.3.1)
|
||||
react-resizable-panels:
|
||||
specifier: 2.1.7
|
||||
version: 2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-select:
|
||||
specifier: 5.9.0
|
||||
version: 5.9.0(@babel/core@7.26.0)(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
|
@ -1992,6 +2019,9 @@ importers:
|
|||
react-window:
|
||||
specifier: 1.8.11
|
||||
version: 1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
recharts:
|
||||
specifier: 2.15.1
|
||||
version: 2.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
regenerator-runtime:
|
||||
specifier: 0.14.1
|
||||
version: 0.14.1
|
||||
|
|
@ -2155,9 +2185,15 @@ importers:
|
|||
|
||||
scripts:
|
||||
devDependencies:
|
||||
'@faker-js/faker':
|
||||
specifier: 9.9.0
|
||||
version: 9.9.0
|
||||
'@graphql-hive/core':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/libraries/core/dist
|
||||
immer:
|
||||
specifier: 10.1.1
|
||||
version: 10.1.1
|
||||
|
||||
packages:
|
||||
|
||||
|
|
@ -2997,10 +3033,6 @@ packages:
|
|||
resolution: {integrity: sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/template@7.25.9':
|
||||
resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/template@7.26.9':
|
||||
resolution: {integrity: sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
|
@ -3581,6 +3613,10 @@ packages:
|
|||
resolution: {integrity: sha512-o41riCGPiOEStayoikBCAqwa6igbv9L9rP+k5UCfQ24EJD/wGrdDs/KTNwkHG5JzDK3T60D5dMkWkLKEPy8gjA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@faker-js/faker@9.9.0':
|
||||
resolution: {integrity: sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==}
|
||||
engines: {node: '>=18.0.0', npm: '>=9.0.0'}
|
||||
|
||||
'@fastify/accept-negotiator@1.1.0':
|
||||
resolution: {integrity: sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==}
|
||||
engines: {node: '>=14'}
|
||||
|
|
@ -3624,6 +3660,7 @@ packages:
|
|||
|
||||
'@fastify/vite@6.0.7':
|
||||
resolution: {integrity: sha512-+dRo9KUkvmbqdmBskG02SwigWl06Mwkw8SBDK1zTNH6vd4DyXbRvI7RmJEmBkLouSU81KTzy1+OzwHSffqSD6w==}
|
||||
bundledDependencies: []
|
||||
|
||||
'@floating-ui/core@1.2.6':
|
||||
resolution: {integrity: sha512-EvYTiXet5XqweYGClEmpu3BoxmsQ4hkj3QaYA6qEnigCWffTP3vNRwBReTdrwDwo7OoJ3wM8Uoe9Uk4n+d4hfg==}
|
||||
|
|
@ -8299,6 +8336,9 @@ packages:
|
|||
'@types/crypto-js@4.2.2':
|
||||
resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==}
|
||||
|
||||
'@types/d3-array@3.2.1':
|
||||
resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
|
||||
|
||||
'@types/d3-array@3.2.2':
|
||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||
|
||||
|
|
@ -8474,12 +8514,18 @@ packages:
|
|||
'@types/json5@0.0.29':
|
||||
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
||||
|
||||
'@types/k6@1.2.0':
|
||||
resolution: {integrity: sha512-7F/vjekOUCzFi5AY+yYBOL38iEtN9EXufdKOAMFkJsdLryJ3/bB/W9jppED1FO5o7zeaPf8M46ardYPD8xgy/w==}
|
||||
|
||||
'@types/katex@0.16.7':
|
||||
resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==}
|
||||
|
||||
'@types/keyv@3.1.4':
|
||||
resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
|
||||
|
||||
'@types/lodash.debounce@4.0.9':
|
||||
resolution: {integrity: sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==}
|
||||
|
||||
'@types/lodash.sortby@4.7.9':
|
||||
resolution: {integrity: sha512-PDmjHnOlndLS59GofH0pnxIs+n9i4CWeXGErSB5JyNFHu2cmvW6mQOaUKjG8EDPkni14IgF8NsRW8bKvFzTm9A==}
|
||||
|
||||
|
|
@ -8887,6 +8933,15 @@ packages:
|
|||
resolution: {integrity: sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A==}
|
||||
engines: {node: '>=14.6'}
|
||||
|
||||
'@xyflow/react@12.4.4':
|
||||
resolution: {integrity: sha512-9RZ9dgKZNJOlbrXXST5HPb5TcXPOIDGondjwcjDro44OQRPl1E0ZRPTeWPGaQtVjbg4WpR4BUYwOeshNI2TuVg==}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
react-dom: '>=17'
|
||||
|
||||
'@xyflow/system@0.0.52':
|
||||
resolution: {integrity: sha512-pJBMaoh/GEebIABWEIxAai0yf57dm+kH7J/Br+LnLFPuJL87Fhcmm4KFWd/bCUy/kCWUg+2/yFAGY0AUHRPOnQ==}
|
||||
|
||||
abbrev@1.1.1:
|
||||
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
|
||||
|
||||
|
|
@ -9601,6 +9656,9 @@ packages:
|
|||
class-variance-authority@0.7.1:
|
||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||
|
||||
classcat@5.0.5:
|
||||
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
|
||||
|
||||
clean-regexp@1.0.0:
|
||||
resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
@ -10133,6 +10191,11 @@ packages:
|
|||
dataloader@2.2.3:
|
||||
resolution: {integrity: sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==}
|
||||
|
||||
date-fns-tz@3.2.0:
|
||||
resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==}
|
||||
peerDependencies:
|
||||
date-fns: ^3.0.0 || ^4.0.0
|
||||
|
||||
date-fns@2.30.0:
|
||||
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
|
||||
engines: {node: '>=0.11'}
|
||||
|
|
@ -10203,9 +10266,16 @@ packages:
|
|||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
decimal.js-light@2.5.1:
|
||||
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||
|
||||
decode-named-character-reference@1.0.2:
|
||||
resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==}
|
||||
|
||||
decode-uri-component@0.4.1:
|
||||
resolution: {integrity: sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
decompress-response@6.0.0:
|
||||
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -10934,6 +11004,10 @@ packages:
|
|||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fast-equals@5.2.2:
|
||||
resolution: {integrity: sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
fast-glob@3.3.2:
|
||||
resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
|
|
@ -11072,6 +11146,10 @@ packages:
|
|||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
filter-obj@5.1.0:
|
||||
resolution: {integrity: sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
finalhandler@1.3.1:
|
||||
resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
|
@ -11886,6 +11964,9 @@ packages:
|
|||
immediate@3.0.6:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
|
||||
immer@10.1.1:
|
||||
resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==}
|
||||
|
||||
immer@10.1.3:
|
||||
resolution: {integrity: sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==}
|
||||
|
||||
|
|
@ -12490,6 +12571,9 @@ packages:
|
|||
resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==}
|
||||
engines: {'0': node >=0.6.0}
|
||||
|
||||
jsurl2@2.2.0:
|
||||
resolution: {integrity: sha512-jFwgc2G7eVMF6/uyxsF+7DbNEg7fj7SRWmJuVfu/SAOK0iIH9gOpLK9FL1jLvENCXS7kYsYcmFxRFQFyQEb7jg==}
|
||||
|
||||
jsx-ast-utils@3.3.5:
|
||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
|
@ -12729,6 +12813,9 @@ packages:
|
|||
lodash.castarray@4.4.0:
|
||||
resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==}
|
||||
|
||||
lodash.debounce@4.0.8:
|
||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||
|
||||
lodash.defaults@4.2.0:
|
||||
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||
|
||||
|
|
@ -14647,6 +14734,10 @@ packages:
|
|||
quansync@0.2.11:
|
||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||
|
||||
query-string@9.1.1:
|
||||
resolution: {integrity: sha512-MWkCOVIcJP9QSKU52Ngow6bsAWAPlPK2MludXvcrS2bGZSl+T1qX9MZvRIkqUIkGLJquMJHWfsT6eRqUpp4aWg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
querystringify@2.2.0:
|
||||
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
|
||||
|
||||
|
|
@ -14761,6 +14852,9 @@ packages:
|
|||
react-is@18.2.0:
|
||||
resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
|
||||
|
||||
react-is@18.3.1:
|
||||
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
|
||||
|
||||
react-medium-image-zoom@5.3.0:
|
||||
resolution: {integrity: sha512-RCIzVlsKqy3BYgGgYbolUfuvx0aSKC7YhX/IJGEp+WJxsqdIVYJHkBdj++FAj6VD7RiWj6VVmdCfa/9vJE9hZg==}
|
||||
peerDependencies:
|
||||
|
|
@ -14811,12 +14905,24 @@ packages:
|
|||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react-resizable-panels@2.1.7:
|
||||
resolution: {integrity: sha512-JtT6gI+nURzhMYQYsx8DKkx6bSoOGFp7A3CwMrOb8y5jFHFyqwo9m68UhmXRw57fRVJksFn1TSlm3ywEQ9vMgA==}
|
||||
peerDependencies:
|
||||
react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
react-select@5.9.0:
|
||||
resolution: {integrity: sha512-nwRKGanVHGjdccsnzhFte/PULziueZxGD8LL2WojON78Mvnq7LdAMEtu2frrwld1fr3geixg3iiMBIc/LLAZpw==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
react-smooth@4.0.4:
|
||||
resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
react-string-replace@1.1.1:
|
||||
resolution: {integrity: sha512-26TUbLzLfHQ5jO5N7y3Mx88eeKo0Ml0UjCQuX4BMfOd/JX+enQqlKpL1CZnmjeBRvQE8TR+ds9j1rqx9CxhKHQ==}
|
||||
engines: {node: '>=0.12.0'}
|
||||
|
|
@ -14942,6 +15048,16 @@ packages:
|
|||
resolution: {integrity: sha512-9FHoNjX1yjuesMwuthAmPKabxYQdOgihFYmT5ebXfYGBcnqXZf3WOVz+5foEZ8Y83P4ZY6yQD5GMmtV+pgCCAQ==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
recharts-scale@0.4.5:
|
||||
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
|
||||
|
||||
recharts@2.15.1:
|
||||
resolution: {integrity: sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==}
|
||||
engines: {node: '>=14'}
|
||||
peerDependencies:
|
||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
recma-build-jsx@1.0.0:
|
||||
resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==}
|
||||
|
||||
|
|
@ -15577,6 +15693,10 @@ packages:
|
|||
split-ca@1.0.1:
|
||||
resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==}
|
||||
|
||||
split-on-first@3.0.0:
|
||||
resolution: {integrity: sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
split2@4.1.0:
|
||||
resolution: {integrity: sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
|
@ -16513,6 +16633,11 @@ packages:
|
|||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
|
||||
use-sync-external-store@1.4.0:
|
||||
resolution: {integrity: sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
use-sync-external-store@1.5.0:
|
||||
resolution: {integrity: sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==}
|
||||
peerDependencies:
|
||||
|
|
@ -16608,6 +16733,9 @@ packages:
|
|||
vfile@6.0.1:
|
||||
resolution: {integrity: sha512-1bYqc7pt6NIADBJ98UiG0Bn/CHIVOoZ/IyEkqIruLg0mE1BKzkOXY2D6CSqQIcKqgadppE5lrxgWXJmXd7zZJw==}
|
||||
|
||||
victory-vendor@36.9.2:
|
||||
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
|
||||
|
||||
vite-node@3.2.4:
|
||||
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
|
||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||
|
|
@ -17006,6 +17134,21 @@ packages:
|
|||
zrender@5.6.1:
|
||||
resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==}
|
||||
|
||||
zustand@4.5.6:
|
||||
resolution: {integrity: sha512-ibr/n1hBzLLj5Y+yUcU7dYw8p6WnIVzdJbnX+1YpaScvZVF2ziugqHs+LAmHw4lWO9c/zRj+K1ncgWDQuthEdQ==}
|
||||
engines: {node: '>=12.7.0'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=16.8'
|
||||
immer: '>=9.0.6'
|
||||
react: '>=16.8'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
immer:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
|
||||
zustand@5.0.8:
|
||||
resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
|
@ -18322,10 +18465,10 @@ snapshots:
|
|||
'@babel/helper-compilation-targets': 7.25.9
|
||||
'@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0)
|
||||
'@babel/helpers': 7.26.10
|
||||
'@babel/parser': 7.26.3
|
||||
'@babel/template': 7.25.9
|
||||
'@babel/parser': 7.26.10
|
||||
'@babel/template': 7.26.9
|
||||
'@babel/traverse': 7.26.4
|
||||
'@babel/types': 7.26.3
|
||||
'@babel/types': 7.26.10
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.1(supports-color@8.1.1)
|
||||
gensync: 1.0.0-beta.2
|
||||
|
|
@ -18635,12 +18778,6 @@ snapshots:
|
|||
dependencies:
|
||||
regenerator-runtime: 0.14.1
|
||||
|
||||
'@babel/template@7.25.9':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.26.2
|
||||
'@babel/parser': 7.26.3
|
||||
'@babel/types': 7.26.10
|
||||
|
||||
'@babel/template@7.26.9':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.26.2
|
||||
|
|
@ -18651,7 +18788,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@babel/code-frame': 7.26.2
|
||||
'@babel/generator': 7.26.3
|
||||
'@babel/parser': 7.26.3
|
||||
'@babel/parser': 7.26.10
|
||||
'@babel/template': 7.26.9
|
||||
'@babel/types': 7.26.10
|
||||
debug: 4.4.1(supports-color@8.1.1)
|
||||
|
|
@ -19339,6 +19476,8 @@ snapshots:
|
|||
|
||||
'@esm2cjs/strip-final-newline@3.0.1-cjs.0': {}
|
||||
|
||||
'@faker-js/faker@9.9.0': {}
|
||||
|
||||
'@fastify/accept-negotiator@1.1.0': {}
|
||||
|
||||
'@fastify/ajv-compiler@3.6.0':
|
||||
|
|
@ -19819,6 +19958,13 @@ snapshots:
|
|||
object-inspect: 1.12.3
|
||||
tslib: 2.6.2
|
||||
|
||||
'@graphql-inspector/core@5.1.0-alpha-20231208113249-34700c8a(graphql@16.11.0)':
|
||||
dependencies:
|
||||
dependency-graph: 0.11.0
|
||||
graphql: 16.11.0
|
||||
object-inspect: 1.12.3
|
||||
tslib: 2.6.2
|
||||
|
||||
'@graphql-inspector/core@5.1.0-alpha-20231208113249-34700c8a(graphql@16.9.0)':
|
||||
dependencies:
|
||||
dependency-graph: 0.11.0
|
||||
|
|
@ -21524,7 +21670,7 @@ snapshots:
|
|||
nopt: 7.2.0
|
||||
proc-log: 3.0.0
|
||||
read-package-json-fast: 3.0.2
|
||||
semver: 7.6.3
|
||||
semver: 7.7.2
|
||||
walk-up-path: 3.0.1
|
||||
|
||||
'@npmcli/fs@3.1.0':
|
||||
|
|
@ -21539,7 +21685,7 @@ snapshots:
|
|||
proc-log: 3.0.0
|
||||
promise-inflight: 1.0.1
|
||||
promise-retry: 2.0.1
|
||||
semver: 7.6.3
|
||||
semver: 7.7.2
|
||||
which: 4.0.0
|
||||
transitivePeerDependencies:
|
||||
- bluebird
|
||||
|
|
@ -25772,8 +25918,8 @@ snapshots:
|
|||
|
||||
'@types/babel__core@7.20.5':
|
||||
dependencies:
|
||||
'@babel/parser': 7.26.3
|
||||
'@babel/types': 7.26.3
|
||||
'@babel/parser': 7.26.10
|
||||
'@babel/types': 7.26.10
|
||||
'@types/babel__generator': 7.6.4
|
||||
'@types/babel__template': 7.4.1
|
||||
'@types/babel__traverse': 7.18.3
|
||||
|
|
@ -25784,7 +25930,7 @@ snapshots:
|
|||
|
||||
'@types/babel__template@7.4.1':
|
||||
dependencies:
|
||||
'@babel/parser': 7.26.3
|
||||
'@babel/parser': 7.26.10
|
||||
'@babel/types': 7.26.10
|
||||
|
||||
'@types/babel__traverse@7.18.3':
|
||||
|
|
@ -25841,6 +25987,8 @@ snapshots:
|
|||
|
||||
'@types/crypto-js@4.2.2': {}
|
||||
|
||||
'@types/d3-array@3.2.1': {}
|
||||
|
||||
'@types/d3-array@3.2.2': {}
|
||||
|
||||
'@types/d3-axis@3.0.6':
|
||||
|
|
@ -26048,12 +26196,18 @@ snapshots:
|
|||
|
||||
'@types/json5@0.0.29': {}
|
||||
|
||||
'@types/k6@1.2.0': {}
|
||||
|
||||
'@types/katex@0.16.7': {}
|
||||
|
||||
'@types/keyv@3.1.4':
|
||||
dependencies:
|
||||
'@types/node': 22.10.5
|
||||
|
||||
'@types/lodash.debounce@4.0.9':
|
||||
dependencies:
|
||||
'@types/lodash': 4.17.14
|
||||
|
||||
'@types/lodash.sortby@4.7.9':
|
||||
dependencies:
|
||||
'@types/lodash': 4.17.13
|
||||
|
|
@ -26312,7 +26466,7 @@ snapshots:
|
|||
globby: 11.1.0
|
||||
is-glob: 4.0.3
|
||||
minimatch: 9.0.5
|
||||
semver: 7.6.3
|
||||
semver: 7.7.2
|
||||
ts-api-utils: 1.3.0(typescript@5.7.3)
|
||||
optionalDependencies:
|
||||
typescript: 5.7.3
|
||||
|
|
@ -26557,6 +26711,27 @@ snapshots:
|
|||
|
||||
'@xmldom/xmldom@0.9.8': {}
|
||||
|
||||
'@xyflow/react@12.4.4(@types/react@18.3.18)(immer@10.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@xyflow/system': 0.0.52
|
||||
classcat: 5.0.5
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
zustand: 4.5.6(@types/react@18.3.18)(immer@10.1.3)(react@18.3.1)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- immer
|
||||
|
||||
'@xyflow/system@0.0.52':
|
||||
dependencies:
|
||||
'@types/d3-drag': 3.0.7
|
||||
'@types/d3-selection': 3.0.11
|
||||
'@types/d3-transition': 3.0.9
|
||||
'@types/d3-zoom': 3.0.8
|
||||
d3-drag: 3.0.0
|
||||
d3-selection: 3.0.0
|
||||
d3-zoom: 3.0.0
|
||||
|
||||
abbrev@1.1.1: {}
|
||||
|
||||
abbrev@2.0.0: {}
|
||||
|
|
@ -27398,6 +27573,8 @@ snapshots:
|
|||
dependencies:
|
||||
clsx: 2.1.1
|
||||
|
||||
classcat@5.0.5: {}
|
||||
|
||||
clean-regexp@1.0.0:
|
||||
dependencies:
|
||||
escape-string-regexp: 1.0.5
|
||||
|
|
@ -27981,6 +28158,10 @@ snapshots:
|
|||
|
||||
dataloader@2.2.3: {}
|
||||
|
||||
date-fns-tz@3.2.0(date-fns@4.1.0):
|
||||
dependencies:
|
||||
date-fns: 4.1.0
|
||||
|
||||
date-fns@2.30.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.10
|
||||
|
|
@ -28025,10 +28206,14 @@ snapshots:
|
|||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
decimal.js-light@2.5.1: {}
|
||||
|
||||
decode-named-character-reference@1.0.2:
|
||||
dependencies:
|
||||
character-entities: 2.0.2
|
||||
|
||||
decode-uri-component@0.4.1: {}
|
||||
|
||||
decompress-response@6.0.0:
|
||||
dependencies:
|
||||
mimic-response: 3.1.0
|
||||
|
|
@ -29008,6 +29193,8 @@ snapshots:
|
|||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-equals@5.2.2: {}
|
||||
|
||||
fast-glob@3.3.2:
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
|
|
@ -29173,6 +29360,8 @@ snapshots:
|
|||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
|
||||
filter-obj@5.1.0: {}
|
||||
|
||||
finalhandler@1.3.1:
|
||||
dependencies:
|
||||
debug: 2.6.9
|
||||
|
|
@ -30244,6 +30433,8 @@ snapshots:
|
|||
|
||||
immediate@3.0.6: {}
|
||||
|
||||
immer@10.1.1: {}
|
||||
|
||||
immer@10.1.3: {}
|
||||
|
||||
immutable@3.7.6: {}
|
||||
|
|
@ -30747,7 +30938,7 @@ snapshots:
|
|||
acorn: 8.14.0
|
||||
eslint-visitor-keys: 3.4.3
|
||||
espree: 9.6.1
|
||||
semver: 7.6.3
|
||||
semver: 7.7.2
|
||||
|
||||
jsonfile@4.0.0:
|
||||
optionalDependencies:
|
||||
|
|
@ -30772,7 +30963,7 @@ snapshots:
|
|||
lodash.isstring: 4.0.1
|
||||
lodash.once: 4.1.1
|
||||
ms: 2.1.3
|
||||
semver: 7.6.3
|
||||
semver: 7.7.2
|
||||
|
||||
jsox@1.2.119: {}
|
||||
|
||||
|
|
@ -30783,6 +30974,8 @@ snapshots:
|
|||
json-schema: 0.4.0
|
||||
verror: 1.10.0
|
||||
|
||||
jsurl2@2.2.0: {}
|
||||
|
||||
jsx-ast-utils@3.3.5:
|
||||
dependencies:
|
||||
array-includes: 3.1.7
|
||||
|
|
@ -31033,6 +31226,8 @@ snapshots:
|
|||
|
||||
lodash.castarray@4.4.0: {}
|
||||
|
||||
lodash.debounce@4.0.8: {}
|
||||
|
||||
lodash.defaults@4.2.0: {}
|
||||
|
||||
lodash.get@4.4.2: {}
|
||||
|
|
@ -32481,7 +32676,7 @@ snapshots:
|
|||
make-fetch-happen: 13.0.0
|
||||
nopt: 7.2.0
|
||||
proc-log: 3.0.0
|
||||
semver: 7.6.3
|
||||
semver: 7.7.2
|
||||
tar: 6.2.1
|
||||
which: 4.0.0
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -33489,6 +33684,12 @@ snapshots:
|
|||
|
||||
quansync@0.2.11: {}
|
||||
|
||||
query-string@9.1.1:
|
||||
dependencies:
|
||||
decode-uri-component: 0.4.1
|
||||
filter-obj: 5.1.0
|
||||
split-on-first: 3.0.0
|
||||
|
||||
querystringify@2.2.0: {}
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
|
@ -33611,6 +33812,8 @@ snapshots:
|
|||
|
||||
react-is@18.2.0: {}
|
||||
|
||||
react-is@18.3.1: {}
|
||||
|
||||
react-medium-image-zoom@5.3.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
|
||||
dependencies:
|
||||
react: 19.0.0
|
||||
|
|
@ -33662,6 +33865,11 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 18.3.18
|
||||
|
||||
react-resizable-panels@2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
react-select@5.9.0(@babel/core@7.26.0)(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.10
|
||||
|
|
@ -33680,6 +33888,14 @@ snapshots:
|
|||
- '@types/react'
|
||||
- supports-color
|
||||
|
||||
react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
fast-equals: 5.2.2
|
||||
prop-types: 15.8.1
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
||||
react-string-replace@1.1.1: {}
|
||||
|
||||
react-style-singleton@2.2.3(@types/react@18.3.18)(react@18.3.1):
|
||||
|
|
@ -33825,6 +34041,23 @@ snapshots:
|
|||
tiny-invariant: 1.3.3
|
||||
tslib: 2.8.1
|
||||
|
||||
recharts-scale@0.4.5:
|
||||
dependencies:
|
||||
decimal.js-light: 2.5.1
|
||||
|
||||
recharts@2.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
eventemitter3: 4.0.7
|
||||
lodash: 4.17.21
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
react-is: 18.3.1
|
||||
react-smooth: 4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
recharts-scale: 0.4.5
|
||||
tiny-invariant: 1.3.3
|
||||
victory-vendor: 36.9.2
|
||||
|
||||
recma-build-jsx@1.0.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
|
@ -34695,6 +34928,8 @@ snapshots:
|
|||
|
||||
split-ca@1.0.1: {}
|
||||
|
||||
split-on-first@3.0.0: {}
|
||||
|
||||
split2@4.1.0: {}
|
||||
|
||||
sponge-case@1.0.1:
|
||||
|
|
@ -35711,6 +35946,10 @@ snapshots:
|
|||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
use-sync-external-store@1.4.0(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
use-sync-external-store@1.5.0(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
|
@ -35818,6 +36057,23 @@ snapshots:
|
|||
unist-util-stringify-position: 4.0.0
|
||||
vfile-message: 4.0.2
|
||||
|
||||
victory-vendor@36.9.2:
|
||||
dependencies:
|
||||
'@types/d3-array': 3.2.1
|
||||
'@types/d3-ease': 3.0.2
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-scale': 4.0.9
|
||||
'@types/d3-shape': 3.1.7
|
||||
'@types/d3-time': 3.0.4
|
||||
'@types/d3-timer': 3.0.2
|
||||
d3-array: 3.2.4
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-scale: 4.0.2
|
||||
d3-shape: 3.2.0
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
vite-node@3.2.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
|
|
@ -36256,6 +36512,14 @@ snapshots:
|
|||
dependencies:
|
||||
tslib: 2.3.0
|
||||
|
||||
zustand@4.5.6(@types/react@18.3.18)(immer@10.1.3)(react@18.3.1):
|
||||
dependencies:
|
||||
use-sync-external-store: 1.4.0(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.18
|
||||
immer: 10.1.3
|
||||
react: 18.3.1
|
||||
|
||||
zustand@5.0.8(@types/react@18.3.18)(immer@10.1.3)(react@19.0.0)(use-sync-external-store@1.5.0(react@19.0.0)):
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.18
|
||||
|
|
|
|||
|
|
@ -9,3 +9,4 @@ packages:
|
|||
- deployment
|
||||
- scripts
|
||||
- rules
|
||||
- load-tests/otel-traces
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
"type": "module",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@graphql-hive/core": "workspace:*"
|
||||
"@faker-js/faker": "9.9.0",
|
||||
"@graphql-hive/core": "workspace:*",
|
||||
"immer": "10.1.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
32
scripts/seed-traces/README.md
Normal file
32
scripts/seed-traces/README.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
## Readme
|
||||
|
||||
Each of the JSON file in this folder contains an array of requests sent to the otel-collector for
|
||||
the specific GraphQL operation.
|
||||
|
||||
All the files will be parsed and used as a template for seeding the clickhouse database with somehow
|
||||
realistic trace data.
|
||||
|
||||
### Usage
|
||||
|
||||
- Create a federation target and publish our federation example
|
||||
(https://the-guild.dev/graphql/hive/docs/get-started/apollo-federation).
|
||||
- Create a organization access token and target to which you want to push the data.
|
||||
|
||||
Then run the following script:
|
||||
|
||||
```
|
||||
HIVE_ORGANIZATION_ACCESS_TOKEN=hvo1/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx= \
|
||||
HIVE_TARGET_REF=the-guild/otel-demo/development \
|
||||
node --experimental-strip-types seed-traces.mts
|
||||
```
|
||||
|
||||
You can adjust the amount of days you want to seed by setting `USAGE_DAYS` (e.g. `USAGE_DAYS=5` will
|
||||
seed the last 5 days). By default we seed the last 14 days.
|
||||
|
||||
You can adjust how frequent the reporting interval is by setting `USAGE_INTERVAL` (e.g.
|
||||
`USAGE_DAYS=20`). By default the value is 20 (minutes).
|
||||
|
||||
### Adding more fixtures
|
||||
|
||||
In case you want to add additional traces starte the intercept server (`interspect-service.mts`),
|
||||
point the gateway to that endpoint and copy the output to a new file within this folder.
|
||||
10
scripts/seed-traces/intercept-server.mts
Normal file
10
scripts/seed-traces/intercept-server.mts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import * as http from 'node:http';
|
||||
import * as foo from '@whatwg-node/server';
|
||||
|
||||
const adapter = foo.createServerAdapter(async request => {
|
||||
console.log(JSON.stringify(await request.json(), null, 2));
|
||||
|
||||
return new Response('{}');
|
||||
});
|
||||
|
||||
http.createServer(adapter).listen(9898);
|
||||
296
scripts/seed-traces/sample-introspection.json
Normal file
296
scripts/seed-traces/sample-introspection.json
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
[
|
||||
{
|
||||
"resourceSpans": [
|
||||
{
|
||||
"resource": {
|
||||
"attributes": [
|
||||
{
|
||||
"key": "service.name",
|
||||
"value": {
|
||||
"stringValue": "hive-gateway"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "service.version",
|
||||
"value": {
|
||||
"stringValue": "5.13.4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0
|
||||
},
|
||||
"scopeSpans": [
|
||||
{
|
||||
"scope": {
|
||||
"name": "gateway"
|
||||
},
|
||||
"spans": [
|
||||
{
|
||||
"traceId": "f42cb37f3811f650f06ca078e7c6625f",
|
||||
"spanId": "c55a1a27cfe0a563",
|
||||
"parentSpanId": "6df96e8ebb5f3dd7",
|
||||
"name": "graphql.parse",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541577384000000",
|
||||
"endTimeUnixNano": "1751541577385261166",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query IntrospectionQuery{__schema{description queryType{name kind}mutationType{name kind}subscriptionType{name kind}types{...FullType}directives{name description locations args(includeDeprecated:true){...InputValue}}}}fragment FullType on __Type{kind name description fields(includeDeprecated:true){name description args(includeDeprecated:true){...InputValue}type{...TypeRef}isDeprecated deprecationReason}inputFields(includeDeprecated:true){...InputValue}interfaces{...TypeRef}enumValues(includeDeprecated:true){name description isDeprecated deprecationReason}possibleTypes{...TypeRef}}fragment InputValue on __InputValue{name description type{...TypeRef}defaultValue isDeprecated deprecationReason}fragment TypeRef on __Type{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name}}}}}}}}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "IntrospectionQuery"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "f42cb37f3811f650f06ca078e7c6625f",
|
||||
"spanId": "ca97bb80da3c0dbe",
|
||||
"parentSpanId": "6df96e8ebb5f3dd7",
|
||||
"name": "graphql.validate",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541577385000000",
|
||||
"endTimeUnixNano": "1751541577397343958",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query IntrospectionQuery{__schema{description queryType{name kind}mutationType{name kind}subscriptionType{name kind}types{...FullType}directives{name description locations args(includeDeprecated:true){...InputValue}}}}fragment FullType on __Type{kind name description fields(includeDeprecated:true){name description args(includeDeprecated:true){...InputValue}type{...TypeRef}isDeprecated deprecationReason}inputFields(includeDeprecated:true){...InputValue}interfaces{...TypeRef}enumValues(includeDeprecated:true){name description isDeprecated deprecationReason}possibleTypes{...TypeRef}}fragment InputValue on __InputValue{name description type{...TypeRef}defaultValue isDeprecated deprecationReason}fragment TypeRef on __Type{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name}}}}}}}}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "IntrospectionQuery"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "f42cb37f3811f650f06ca078e7c6625f",
|
||||
"spanId": "697d38f300df2167",
|
||||
"parentSpanId": "6df96e8ebb5f3dd7",
|
||||
"name": "graphql.context",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541577398000000",
|
||||
"endTimeUnixNano": "1751541577399414208",
|
||||
"attributes": [],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "f42cb37f3811f650f06ca078e7c6625f",
|
||||
"spanId": "f479d33b7320cd0f",
|
||||
"parentSpanId": "6df96e8ebb5f3dd7",
|
||||
"name": "graphql.execute",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541577400000000",
|
||||
"endTimeUnixNano": "1751541577410081959",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "IntrospectionQuery"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query IntrospectionQuery{__schema{description queryType{name kind}mutationType{name kind}subscriptionType{name kind}types{...FullType}directives{name description locations args(includeDeprecated:true){...InputValue}}}}fragment FullType on __Type{kind name description fields(includeDeprecated:true){name description args(includeDeprecated:true){...InputValue}type{...TypeRef}isDeprecated deprecationReason}inputFields(includeDeprecated:true){...InputValue}interfaces{...TypeRef}enumValues(includeDeprecated:true){name description isDeprecated deprecationReason}possibleTypes{...TypeRef}}fragment InputValue on __InputValue{name description type{...TypeRef}defaultValue isDeprecated deprecationReason}fragment TypeRef on __Type{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name}}}}}}}}}}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "f42cb37f3811f650f06ca078e7c6625f",
|
||||
"spanId": "6df96e8ebb5f3dd7",
|
||||
"parentSpanId": "af8929e2e40e00af",
|
||||
"name": "graphql.operation IntrospectionQuery",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541577382000000",
|
||||
"endTimeUnixNano": "1751541577409557042",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query IntrospectionQuery{__schema{description queryType{name kind}mutationType{name kind}subscriptionType{name kind}types{...FullType}directives{name description locations args(includeDeprecated:true){...InputValue}}}}fragment FullType on __Type{kind name description fields(includeDeprecated:true){name description args(includeDeprecated:true){...InputValue}type{...TypeRef}isDeprecated deprecationReason}inputFields(includeDeprecated:true){...InputValue}interfaces{...TypeRef}enumValues(includeDeprecated:true){name description isDeprecated deprecationReason}possibleTypes{...TypeRef}}fragment InputValue on __InputValue{name description type{...TypeRef}defaultValue isDeprecated deprecationReason}fragment TypeRef on __Type{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name}}}}}}}}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "IntrospectionQuery"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "gateway.cache.response_cache",
|
||||
"value": {
|
||||
"stringValue": "miss"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "f42cb37f3811f650f06ca078e7c6625f",
|
||||
"spanId": "af8929e2e40e00af",
|
||||
"name": "query IntrospectionQuery",
|
||||
"kind": 2,
|
||||
"startTimeUnixNano": "1751541577154000000",
|
||||
"endTimeUnixNano": "1751541577412387709",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "http.method",
|
||||
"value": {
|
||||
"stringValue": "POST"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.url",
|
||||
"value": {
|
||||
"stringValue": "http://localhost:4000/graphql"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.route",
|
||||
"value": {
|
||||
"stringValue": "/graphql"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.scheme",
|
||||
"value": {
|
||||
"stringValue": "http:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "net.host.name",
|
||||
"value": {
|
||||
"stringValue": "localhost"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.host",
|
||||
"value": {
|
||||
"stringValue": "localhost:4000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.user_agent",
|
||||
"value": {
|
||||
"stringValue": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "IntrospectionQuery"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query IntrospectionQuery{__schema{description queryType{name kind}mutationType{name kind}subscriptionType{name kind}types{...FullType}directives{name description locations args(includeDeprecated:true){...InputValue}}}}fragment FullType on __Type{kind name description fields(includeDeprecated:true){name description args(includeDeprecated:true){...InputValue}type{...TypeRef}isDeprecated deprecationReason}inputFields(includeDeprecated:true){...InputValue}interfaces{...TypeRef}enumValues(includeDeprecated:true){name description isDeprecated deprecationReason}possibleTypes{...TypeRef}}fragment InputValue on __InputValue{name description type{...TypeRef}defaultValue isDeprecated deprecationReason}fragment TypeRef on __Type{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name}}}}}}}}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.graphql.error.count",
|
||||
"value": {
|
||||
"intValue": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.status_code",
|
||||
"value": {
|
||||
"intValue": 200
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "gateway.cache.response_cache",
|
||||
"value": {
|
||||
"stringValue": "miss"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.graphql",
|
||||
"value": {
|
||||
"boolValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 1
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
788
scripts/seed-traces/sample-my-profile.json
Normal file
788
scripts/seed-traces/sample-my-profile.json
Normal file
|
|
@ -0,0 +1,788 @@
|
|||
[
|
||||
{
|
||||
"resourceSpans": [
|
||||
{
|
||||
"resource": {
|
||||
"attributes": [
|
||||
{
|
||||
"key": "service.name",
|
||||
"value": {
|
||||
"stringValue": "hive-gateway"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "service.version",
|
||||
"value": {
|
||||
"stringValue": "5.13.4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0
|
||||
},
|
||||
"scopeSpans": [
|
||||
{
|
||||
"scope": {
|
||||
"name": "gateway"
|
||||
},
|
||||
"spans": [
|
||||
{
|
||||
"traceId": "807b71f8b7b4f9c827ce8220c6540eac",
|
||||
"spanId": "a64ae7143330edd5",
|
||||
"parentSpanId": "53b24c8e7ff72fa4",
|
||||
"name": "graphql.parse",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541696992000000",
|
||||
"endTimeUnixNano": "1751541696992404084",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query MyProfile{me{id username name reviews{id product{upc name}}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "MyProfile"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "807b71f8b7b4f9c827ce8220c6540eac",
|
||||
"spanId": "a58c9a61e1c74cee",
|
||||
"parentSpanId": "53b24c8e7ff72fa4",
|
||||
"name": "graphql.validate",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541696993000000",
|
||||
"endTimeUnixNano": "1751541696994822625",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query MyProfile{me{id username name reviews{id product{upc name}}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "MyProfile"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "807b71f8b7b4f9c827ce8220c6540eac",
|
||||
"spanId": "8fc953974f3a8242",
|
||||
"parentSpanId": "53b24c8e7ff72fa4",
|
||||
"name": "graphql.context",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541696994000000",
|
||||
"endTimeUnixNano": "1751541696994156500",
|
||||
"attributes": [],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"resourceSpans": [
|
||||
{
|
||||
"resource": {
|
||||
"attributes": [
|
||||
{
|
||||
"key": "service.name",
|
||||
"value": {
|
||||
"stringValue": "hive-gateway"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "service.version",
|
||||
"value": {
|
||||
"stringValue": "5.13.4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0
|
||||
},
|
||||
"scopeSpans": [
|
||||
{
|
||||
"scope": {
|
||||
"name": "gateway"
|
||||
},
|
||||
"spans": [
|
||||
{
|
||||
"traceId": "807b71f8b7b4f9c827ce8220c6540eac",
|
||||
"spanId": "db7a050eb7c01cc6",
|
||||
"parentSpanId": "53e7ea110fc140bc",
|
||||
"name": "http.fetch",
|
||||
"kind": 3,
|
||||
"startTimeUnixNano": "1751541697013000000",
|
||||
"endTimeUnixNano": "1751541697246498001",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "http.method",
|
||||
"value": {
|
||||
"stringValue": "POST"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.url",
|
||||
"value": {
|
||||
"stringValue": "https://federation-demo.theguild.workers.dev/users"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "net.host.name",
|
||||
"value": {
|
||||
"stringValue": "federation-demo.theguild.workers.dev"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.host",
|
||||
"value": {
|
||||
"stringValue": "federation-demo.theguild.workers.dev"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.route",
|
||||
"value": {
|
||||
"stringValue": "/users"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.scheme",
|
||||
"value": {
|
||||
"stringValue": "https:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.status_code",
|
||||
"value": {
|
||||
"intValue": 200
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 1
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "807b71f8b7b4f9c827ce8220c6540eac",
|
||||
"spanId": "53e7ea110fc140bc",
|
||||
"parentSpanId": "5950033302028628",
|
||||
"name": "subgraph.execute (users)",
|
||||
"kind": 3,
|
||||
"startTimeUnixNano": "1751541697012000000",
|
||||
"endTimeUnixNano": "1751541697247851792",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "MyProfile"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query MyProfile{__typename me{__typename id id username name}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "gateway.upstream.subgraph.name",
|
||||
"value": {
|
||||
"stringValue": "users"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.gateway.upstream.subgraph.name",
|
||||
"value": {
|
||||
"stringValue": "users"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query MyProfile{__typename me{__typename id id username name}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "MyProfile"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"resourceSpans": [
|
||||
{
|
||||
"resource": {
|
||||
"attributes": [
|
||||
{
|
||||
"key": "service.name",
|
||||
"value": {
|
||||
"stringValue": "hive-gateway"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "service.version",
|
||||
"value": {
|
||||
"stringValue": "5.13.4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0
|
||||
},
|
||||
"scopeSpans": [
|
||||
{
|
||||
"scope": {
|
||||
"name": "gateway"
|
||||
},
|
||||
"spans": [
|
||||
{
|
||||
"traceId": "807b71f8b7b4f9c827ce8220c6540eac",
|
||||
"spanId": "600df68244bcc074",
|
||||
"parentSpanId": "3fa530a61e87352d",
|
||||
"name": "http.fetch",
|
||||
"kind": 3,
|
||||
"startTimeUnixNano": "1751541697254000000",
|
||||
"endTimeUnixNano": "1751541697287733167",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "http.method",
|
||||
"value": {
|
||||
"stringValue": "POST"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.url",
|
||||
"value": {
|
||||
"stringValue": "https://federation-demo.theguild.workers.dev/reviews"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "net.host.name",
|
||||
"value": {
|
||||
"stringValue": "federation-demo.theguild.workers.dev"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.host",
|
||||
"value": {
|
||||
"stringValue": "federation-demo.theguild.workers.dev"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.route",
|
||||
"value": {
|
||||
"stringValue": "/reviews"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.scheme",
|
||||
"value": {
|
||||
"stringValue": "https:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.status_code",
|
||||
"value": {
|
||||
"intValue": 200
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 1
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "807b71f8b7b4f9c827ce8220c6540eac",
|
||||
"spanId": "3fa530a61e87352d",
|
||||
"parentSpanId": "5950033302028628",
|
||||
"name": "subgraph.execute (reviews)",
|
||||
"kind": 3,
|
||||
"startTimeUnixNano": "1751541697254000000",
|
||||
"endTimeUnixNano": "1751541697288384625",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "MyProfile"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query MyProfile($representations:[_Any!]!){__typename _entities(representations:$representations){__typename ...on User{id reviews{__typename id id product{__typename upc upc}}}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "gateway.upstream.subgraph.name",
|
||||
"value": {
|
||||
"stringValue": "reviews"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.gateway.upstream.subgraph.name",
|
||||
"value": {
|
||||
"stringValue": "reviews"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query MyProfile($representations:[_Any!]!){__typename _entities(representations:$representations){__typename ...on User{id reviews{__typename id id product{__typename upc upc}}}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "MyProfile"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"resourceSpans": [
|
||||
{
|
||||
"resource": {
|
||||
"attributes": [
|
||||
{
|
||||
"key": "service.name",
|
||||
"value": {
|
||||
"stringValue": "hive-gateway"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "service.version",
|
||||
"value": {
|
||||
"stringValue": "5.13.4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0
|
||||
},
|
||||
"scopeSpans": [
|
||||
{
|
||||
"scope": {
|
||||
"name": "gateway"
|
||||
},
|
||||
"spans": [
|
||||
{
|
||||
"traceId": "807b71f8b7b4f9c827ce8220c6540eac",
|
||||
"spanId": "e6f23702d0c6dca1",
|
||||
"parentSpanId": "01288c4b66e39646",
|
||||
"name": "http.fetch",
|
||||
"kind": 3,
|
||||
"startTimeUnixNano": "1751541697291000000",
|
||||
"endTimeUnixNano": "1751541697328195667",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "http.method",
|
||||
"value": {
|
||||
"stringValue": "POST"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.url",
|
||||
"value": {
|
||||
"stringValue": "https://federation-demo.theguild.workers.dev/products"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "net.host.name",
|
||||
"value": {
|
||||
"stringValue": "federation-demo.theguild.workers.dev"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.host",
|
||||
"value": {
|
||||
"stringValue": "federation-demo.theguild.workers.dev"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.route",
|
||||
"value": {
|
||||
"stringValue": "/products"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.scheme",
|
||||
"value": {
|
||||
"stringValue": "https:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.status_code",
|
||||
"value": {
|
||||
"intValue": 200
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 1
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "807b71f8b7b4f9c827ce8220c6540eac",
|
||||
"spanId": "01288c4b66e39646",
|
||||
"parentSpanId": "5950033302028628",
|
||||
"name": "subgraph.execute (products)",
|
||||
"kind": 3,
|
||||
"startTimeUnixNano": "1751541697291000000",
|
||||
"endTimeUnixNano": "1751541697328841166",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "MyProfile"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query MyProfile($representations:[_Any!]!){__typename _entities(representations:$representations){__typename ...on Product{upc name}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "gateway.upstream.subgraph.name",
|
||||
"value": {
|
||||
"stringValue": "products"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.gateway.upstream.subgraph.name",
|
||||
"value": {
|
||||
"stringValue": "products"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query MyProfile($representations:[_Any!]!){__typename _entities(representations:$representations){__typename ...on Product{upc name}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "MyProfile"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "807b71f8b7b4f9c827ce8220c6540eac",
|
||||
"spanId": "5950033302028628",
|
||||
"parentSpanId": "53b24c8e7ff72fa4",
|
||||
"name": "graphql.execute",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541696995000000",
|
||||
"endTimeUnixNano": "1751541697329399042",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "MyProfile"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query MyProfile{me{id username name reviews{id product{upc name}}}}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "807b71f8b7b4f9c827ce8220c6540eac",
|
||||
"spanId": "53b24c8e7ff72fa4",
|
||||
"parentSpanId": "af9ade51d3bdaa1e",
|
||||
"name": "graphql.operation MyProfile",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541696992000000",
|
||||
"endTimeUnixNano": "1751541697329427167",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query MyProfile{me{id username name reviews{id product{upc name}}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "MyProfile"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "gateway.cache.response_cache",
|
||||
"value": {
|
||||
"stringValue": "miss"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "807b71f8b7b4f9c827ce8220c6540eac",
|
||||
"spanId": "af9ade51d3bdaa1e",
|
||||
"name": "query MyProfile",
|
||||
"kind": 2,
|
||||
"startTimeUnixNano": "1751541696982000000",
|
||||
"endTimeUnixNano": "1751541697329384584",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "http.method",
|
||||
"value": {
|
||||
"stringValue": "POST"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.url",
|
||||
"value": {
|
||||
"stringValue": "http://localhost:4000/graphql"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.route",
|
||||
"value": {
|
||||
"stringValue": "/graphql"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.scheme",
|
||||
"value": {
|
||||
"stringValue": "http:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "net.host.name",
|
||||
"value": {
|
||||
"stringValue": "localhost"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.host",
|
||||
"value": {
|
||||
"stringValue": "localhost:4000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.user_agent",
|
||||
"value": {
|
||||
"stringValue": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.gateway.operation.subgraph.names",
|
||||
"value": {
|
||||
"arrayValue": {
|
||||
"values": [
|
||||
{ "stringValue": "users" },
|
||||
{ "stringValue": "reviews" },
|
||||
{ "stringValue": "products" }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "MyProfile"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query MyProfile{me{id username name reviews{id product{upc name}}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.graphql.error.count",
|
||||
"value": {
|
||||
"intValue": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.status_code",
|
||||
"value": {
|
||||
"intValue": 200
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "gateway.cache.response_cache",
|
||||
"value": {
|
||||
"stringValue": "miss"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.graphql",
|
||||
"value": {
|
||||
"boolValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 1
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
788
scripts/seed-traces/sample-products-overview.json
Normal file
788
scripts/seed-traces/sample-products-overview.json
Normal file
|
|
@ -0,0 +1,788 @@
|
|||
[
|
||||
{
|
||||
"resourceSpans": [
|
||||
{
|
||||
"resource": {
|
||||
"attributes": [
|
||||
{
|
||||
"key": "service.name",
|
||||
"value": {
|
||||
"stringValue": "hive-gateway"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "service.version",
|
||||
"value": {
|
||||
"stringValue": "5.13.4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0
|
||||
},
|
||||
"scopeSpans": [
|
||||
{
|
||||
"scope": {
|
||||
"name": "gateway"
|
||||
},
|
||||
"spans": [
|
||||
{
|
||||
"traceId": "beec0bf1fc0eed2ef7721f6b4827be86",
|
||||
"spanId": "1b80c5676106d88c",
|
||||
"parentSpanId": "94c73be93cd98d46",
|
||||
"name": "graphql.parse",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541486715000000",
|
||||
"endTimeUnixNano": "1751541486715238750",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query TopProductsOverview{topProducts{name upc price weight reviews{id body author{id username name}}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "TopProductsOverview"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "beec0bf1fc0eed2ef7721f6b4827be86",
|
||||
"spanId": "d7d5f35afa053c99",
|
||||
"parentSpanId": "94c73be93cd98d46",
|
||||
"name": "graphql.validate",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541486715000000",
|
||||
"endTimeUnixNano": "1751541486715310750",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query TopProductsOverview{topProducts{name upc price weight reviews{id body author{id username name}}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "TopProductsOverview"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "beec0bf1fc0eed2ef7721f6b4827be86",
|
||||
"spanId": "2ea02406bc1b76d1",
|
||||
"parentSpanId": "94c73be93cd98d46",
|
||||
"name": "graphql.context",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541486716000000",
|
||||
"endTimeUnixNano": "1751541486716082041",
|
||||
"attributes": [],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"resourceSpans": [
|
||||
{
|
||||
"resource": {
|
||||
"attributes": [
|
||||
{
|
||||
"key": "service.name",
|
||||
"value": {
|
||||
"stringValue": "hive-gateway"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "service.version",
|
||||
"value": {
|
||||
"stringValue": "5.13.4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0
|
||||
},
|
||||
"scopeSpans": [
|
||||
{
|
||||
"scope": {
|
||||
"name": "gateway"
|
||||
},
|
||||
"spans": [
|
||||
{
|
||||
"traceId": "beec0bf1fc0eed2ef7721f6b4827be86",
|
||||
"spanId": "4b3aa6b08fd35de1",
|
||||
"parentSpanId": "2707eb31347c2542",
|
||||
"name": "http.fetch",
|
||||
"kind": 3,
|
||||
"startTimeUnixNano": "1751541486718000000",
|
||||
"endTimeUnixNano": "1751541486925992541",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "http.method",
|
||||
"value": {
|
||||
"stringValue": "POST"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.url",
|
||||
"value": {
|
||||
"stringValue": "https://federation-demo.theguild.workers.dev/products"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "net.host.name",
|
||||
"value": {
|
||||
"stringValue": "federation-demo.theguild.workers.dev"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.host",
|
||||
"value": {
|
||||
"stringValue": "federation-demo.theguild.workers.dev"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.route",
|
||||
"value": {
|
||||
"stringValue": "/products"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.scheme",
|
||||
"value": {
|
||||
"stringValue": "https:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.status_code",
|
||||
"value": {
|
||||
"intValue": 200
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 1
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "beec0bf1fc0eed2ef7721f6b4827be86",
|
||||
"spanId": "2707eb31347c2542",
|
||||
"parentSpanId": "0deba53eb2628007",
|
||||
"name": "subgraph.execute (products)",
|
||||
"kind": 3,
|
||||
"startTimeUnixNano": "1751541486718000000",
|
||||
"endTimeUnixNano": "1751541486926520250",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "TopProductsOverview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query TopProductsOverview($first:Int){__typename topProducts(first:$first){__typename upc name upc price weight}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "gateway.upstream.subgraph.name",
|
||||
"value": {
|
||||
"stringValue": "products"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.gateway.upstream.subgraph.name",
|
||||
"value": {
|
||||
"stringValue": "products"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query TopProductsOverview($first:Int){__typename topProducts(first:$first){__typename upc name upc price weight}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "TopProductsOverview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"resourceSpans": [
|
||||
{
|
||||
"resource": {
|
||||
"attributes": [
|
||||
{
|
||||
"key": "service.name",
|
||||
"value": {
|
||||
"stringValue": "hive-gateway"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "service.version",
|
||||
"value": {
|
||||
"stringValue": "5.13.4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0
|
||||
},
|
||||
"scopeSpans": [
|
||||
{
|
||||
"scope": {
|
||||
"name": "gateway"
|
||||
},
|
||||
"spans": [
|
||||
{
|
||||
"traceId": "beec0bf1fc0eed2ef7721f6b4827be86",
|
||||
"spanId": "f81b8ed0b08b23f7",
|
||||
"parentSpanId": "233473cfb358e83f",
|
||||
"name": "http.fetch",
|
||||
"kind": 3,
|
||||
"startTimeUnixNano": "1751541486928000000",
|
||||
"endTimeUnixNano": "1751541486959338875",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "http.method",
|
||||
"value": {
|
||||
"stringValue": "POST"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.url",
|
||||
"value": {
|
||||
"stringValue": "https://federation-demo.theguild.workers.dev/reviews"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "net.host.name",
|
||||
"value": {
|
||||
"stringValue": "federation-demo.theguild.workers.dev"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.host",
|
||||
"value": {
|
||||
"stringValue": "federation-demo.theguild.workers.dev"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.route",
|
||||
"value": {
|
||||
"stringValue": "/reviews"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.scheme",
|
||||
"value": {
|
||||
"stringValue": "https:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.status_code",
|
||||
"value": {
|
||||
"intValue": 200
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 1
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "beec0bf1fc0eed2ef7721f6b4827be86",
|
||||
"spanId": "233473cfb358e83f",
|
||||
"parentSpanId": "0deba53eb2628007",
|
||||
"name": "subgraph.execute (reviews)",
|
||||
"kind": 3,
|
||||
"startTimeUnixNano": "1751541486927000000",
|
||||
"endTimeUnixNano": "1751541486959231209",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "TopProductsOverview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query TopProductsOverview($representations:[_Any!]!){__typename _entities(representations:$representations){__typename ...on Product{upc reviews{__typename id id body author{username __typename __typename id id}}}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "gateway.upstream.subgraph.name",
|
||||
"value": {
|
||||
"stringValue": "reviews"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.gateway.upstream.subgraph.name",
|
||||
"value": {
|
||||
"stringValue": "reviews"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query TopProductsOverview($representations:[_Any!]!){__typename _entities(representations:$representations){__typename ...on Product{upc reviews{__typename id id body author{username __typename __typename id id}}}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "TopProductsOverview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"resourceSpans": [
|
||||
{
|
||||
"resource": {
|
||||
"attributes": [
|
||||
{
|
||||
"key": "service.name",
|
||||
"value": {
|
||||
"stringValue": "hive-gateway"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "service.version",
|
||||
"value": {
|
||||
"stringValue": "5.13.4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0
|
||||
},
|
||||
"scopeSpans": [
|
||||
{
|
||||
"scope": {
|
||||
"name": "gateway"
|
||||
},
|
||||
"spans": [
|
||||
{
|
||||
"traceId": "beec0bf1fc0eed2ef7721f6b4827be86",
|
||||
"spanId": "f8652f4f214127da",
|
||||
"parentSpanId": "6bfe00463408e3de",
|
||||
"name": "http.fetch",
|
||||
"kind": 3,
|
||||
"startTimeUnixNano": "1751541486962000000",
|
||||
"endTimeUnixNano": "1751541486998254500",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "http.method",
|
||||
"value": {
|
||||
"stringValue": "POST"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.url",
|
||||
"value": {
|
||||
"stringValue": "https://federation-demo.theguild.workers.dev/users"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "net.host.name",
|
||||
"value": {
|
||||
"stringValue": "federation-demo.theguild.workers.dev"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.host",
|
||||
"value": {
|
||||
"stringValue": "federation-demo.theguild.workers.dev"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.route",
|
||||
"value": {
|
||||
"stringValue": "/users"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.scheme",
|
||||
"value": {
|
||||
"stringValue": "https:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.status_code",
|
||||
"value": {
|
||||
"intValue": 200
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 1
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "beec0bf1fc0eed2ef7721f6b4827be86",
|
||||
"spanId": "6bfe00463408e3de",
|
||||
"parentSpanId": "0deba53eb2628007",
|
||||
"name": "subgraph.execute (users)",
|
||||
"kind": 3,
|
||||
"startTimeUnixNano": "1751541486961000000",
|
||||
"endTimeUnixNano": "1751541486998039417",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "TopProductsOverview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query TopProductsOverview($representations:[_Any!]!){__typename _entities(representations:$representations){__typename ...on User{id name}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "gateway.upstream.subgraph.name",
|
||||
"value": {
|
||||
"stringValue": "users"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.gateway.upstream.subgraph.name",
|
||||
"value": {
|
||||
"stringValue": "users"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query TopProductsOverview($representations:[_Any!]!){__typename _entities(representations:$representations){__typename ...on User{id name}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "TopProductsOverview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "beec0bf1fc0eed2ef7721f6b4827be86",
|
||||
"spanId": "0deba53eb2628007",
|
||||
"parentSpanId": "94c73be93cd98d46",
|
||||
"name": "graphql.execute",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541486716000000",
|
||||
"endTimeUnixNano": "1751541486999119791",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "TopProductsOverview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query TopProductsOverview{topProducts{name upc price weight reviews{id body author{id username name}}}}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "beec0bf1fc0eed2ef7721f6b4827be86",
|
||||
"spanId": "94c73be93cd98d46",
|
||||
"parentSpanId": "93b59a82d5aab081",
|
||||
"name": "graphql.operation TopProductsOverview",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541486715000000",
|
||||
"endTimeUnixNano": "1751541486999203084",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query TopProductsOverview{topProducts{name upc price weight reviews{id body author{id username name}}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "TopProductsOverview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "gateway.cache.response_cache",
|
||||
"value": {
|
||||
"stringValue": "miss"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "beec0bf1fc0eed2ef7721f6b4827be86",
|
||||
"spanId": "93b59a82d5aab081",
|
||||
"name": "query TopProductsOverview",
|
||||
"kind": 2,
|
||||
"startTimeUnixNano": "1751541486711000000",
|
||||
"endTimeUnixNano": "1751541486998914125",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "http.method",
|
||||
"value": {
|
||||
"stringValue": "POST"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.url",
|
||||
"value": {
|
||||
"stringValue": "http://localhost:4000/graphql"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.route",
|
||||
"value": {
|
||||
"stringValue": "/graphql"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.scheme",
|
||||
"value": {
|
||||
"stringValue": "http:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "net.host.name",
|
||||
"value": {
|
||||
"stringValue": "localhost"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.host",
|
||||
"value": {
|
||||
"stringValue": "localhost:4000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.user_agent",
|
||||
"value": {
|
||||
"stringValue": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.gateway.operation.subgraph.names",
|
||||
"value": {
|
||||
"arrayValue": {
|
||||
"values": [
|
||||
{ "stringValue": "users" },
|
||||
{ "stringValue": "reviews" },
|
||||
{ "stringValue": "products" }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "TopProductsOverview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query TopProductsOverview{topProducts{name upc price weight reviews{id body author{id username name}}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.graphql.error.count",
|
||||
"value": {
|
||||
"intValue": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.status_code",
|
||||
"value": {
|
||||
"intValue": 200
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "gateway.cache.response_cache",
|
||||
"value": {
|
||||
"stringValue": "miss"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.graphql",
|
||||
"value": {
|
||||
"boolValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 1
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,330 @@
|
|||
[
|
||||
{
|
||||
"resourceSpans": [
|
||||
{
|
||||
"resource": {
|
||||
"attributes": [
|
||||
{
|
||||
"key": "service.name",
|
||||
"value": {
|
||||
"stringValue": "hive-gateway"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "service.version",
|
||||
"value": {
|
||||
"stringValue": "5.13.4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0
|
||||
},
|
||||
"scopeSpans": [
|
||||
{
|
||||
"scope": {
|
||||
"name": "gateway"
|
||||
},
|
||||
"spans": [
|
||||
{
|
||||
"traceId": "b05d5ce706fe45f33fd02f12ff2b538c",
|
||||
"spanId": "0424db5a14505873",
|
||||
"parentSpanId": "80d07c2b29e6a98f",
|
||||
"name": "graphql.parse",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541923666000000",
|
||||
"endTimeUnixNano": "1751541923666275959",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query UserReview($id:ID!){user(id:$id){id reviews{id product{upc name weight}body}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "UserReview"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "b05d5ce706fe45f33fd02f12ff2b538c",
|
||||
"spanId": "c2b0e3a5107b7d8d",
|
||||
"parentSpanId": "80d07c2b29e6a98f",
|
||||
"name": "graphql.validate",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541923666000000",
|
||||
"endTimeUnixNano": "1751541923666099959",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query UserReview($id:ID!){user(id:$id){id reviews{id product{upc name weight}body}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "UserReview"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "b05d5ce706fe45f33fd02f12ff2b538c",
|
||||
"spanId": "51a8cf06eab30356",
|
||||
"parentSpanId": "80d07c2b29e6a98f",
|
||||
"name": "graphql.context",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541923666000000",
|
||||
"endTimeUnixNano": "1751541923666065666",
|
||||
"attributes": [],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "b05d5ce706fe45f33fd02f12ff2b538c",
|
||||
"spanId": "262de13a78519369",
|
||||
"parentSpanId": "80d07c2b29e6a98f",
|
||||
"name": "graphql.execute",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541923666000000",
|
||||
"endTimeUnixNano": "1751541923667922500",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "UserReview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query UserReview($id:ID!){user(id:$id){id reviews{id product{upc name weight}body}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.error.count",
|
||||
"value": {
|
||||
"intValue": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [
|
||||
{
|
||||
"attributes": [
|
||||
{
|
||||
"key": "exception.type",
|
||||
"value": {
|
||||
"stringValue": "GraphQLError"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "exception.message",
|
||||
"value": {
|
||||
"stringValue": "Variable \"$id\" of required type \"ID!\" was not provided."
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "exception.stacktrace",
|
||||
"value": {
|
||||
"stringValue": "GraphQLError: Variable \"$id\" of required type \"ID!\" was not provided.\n at createGraphQLError (file:///node_modules/.chunk/abortSignalAny-BG8Lg0X_.mjs:29:12)\n at coerceVariableValues (file:///node_modules/.chunk/normalizedExecutor-C4nYkuV1.mjs:566:25)\n at getVariableValues (file:///node_modules/.chunk/normalizedExecutor-C4nYkuV1.mjs:533:25)\n at buildExecutionContext (file:///node_modules/.chunk/normalizedExecutor-C4nYkuV1.mjs:746:35)\n at execute (file:///node_modules/.chunk/normalizedExecutor-C4nYkuV1.mjs:615:24)\n at file:///node_modules/.chunk/normalizedExecutor-C4nYkuV1.mjs:1960:37\n at Promise.then (file:///node_modules/.chunk/index-DYl9M8N-.mjs:31:40)\n at handleMaybePromise (file:///node_modules/.chunk/index-DYl9M8N-.mjs:10:33)\n at normalizedExecutor (file:///node_modules/.chunk/normalizedExecutor-C4nYkuV1.mjs:1960:12)\n at _4.args (file:///node_modules/.chunk/use-engine-CPlZaEw6.mjs:580:17)"
|
||||
}
|
||||
}
|
||||
],
|
||||
"name": "exception",
|
||||
"timeUnixNano": "1751541923667636834",
|
||||
"droppedAttributesCount": 0
|
||||
}
|
||||
],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 2,
|
||||
"message": "Variable \"$id\" of required type \"ID!\" was not provided."
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "b05d5ce706fe45f33fd02f12ff2b538c",
|
||||
"spanId": "80d07c2b29e6a98f",
|
||||
"parentSpanId": "86ebca8621ea012f",
|
||||
"name": "graphql.operation UserReview",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541923665000000",
|
||||
"endTimeUnixNano": "1751541923667755750",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query UserReview($id:ID!){user(id:$id){id reviews{id product{upc name weight}body}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "UserReview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "gateway.cache.response_cache",
|
||||
"value": {
|
||||
"stringValue": "miss"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "b05d5ce706fe45f33fd02f12ff2b538c",
|
||||
"spanId": "86ebca8621ea012f",
|
||||
"name": "query UserReview",
|
||||
"kind": 2,
|
||||
"startTimeUnixNano": "1751541923662000000",
|
||||
"endTimeUnixNano": "1751541923668269583",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "http.method",
|
||||
"value": {
|
||||
"stringValue": "POST"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.url",
|
||||
"value": {
|
||||
"stringValue": "http://localhost:4000/graphql"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.route",
|
||||
"value": {
|
||||
"stringValue": "/graphql"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.scheme",
|
||||
"value": {
|
||||
"stringValue": "http:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "net.host.name",
|
||||
"value": {
|
||||
"stringValue": "localhost"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.host",
|
||||
"value": {
|
||||
"stringValue": "localhost:4000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.user_agent",
|
||||
"value": {
|
||||
"stringValue": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "UserReview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query UserReview($id:ID!){user(id:$id){id reviews{id product{upc name weight}body}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.graphql.error.count",
|
||||
"value": {
|
||||
"intValue": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.status_code",
|
||||
"value": {
|
||||
"intValue": 400
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "gateway.cache.response_cache",
|
||||
"value": {
|
||||
"stringValue": "miss"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.graphql",
|
||||
"value": {
|
||||
"boolValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 2,
|
||||
"message": "Bad Request"
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
464
scripts/seed-traces/sample-user-review-not-found.json
Normal file
464
scripts/seed-traces/sample-user-review-not-found.json
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
[
|
||||
{
|
||||
"resourceSpans": [
|
||||
{
|
||||
"resource": {
|
||||
"attributes": [
|
||||
{
|
||||
"key": "service.name",
|
||||
"value": {
|
||||
"stringValue": "hive-gateway"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "service.version",
|
||||
"value": {
|
||||
"stringValue": "5.13.4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0
|
||||
},
|
||||
"scopeSpans": [
|
||||
{
|
||||
"scope": {
|
||||
"name": "gateway"
|
||||
},
|
||||
"spans": [
|
||||
{
|
||||
"traceId": "395970847a47f14794c1c1af2499eb94",
|
||||
"spanId": "1a647eb9b4147b10",
|
||||
"parentSpanId": "608141aa264650c6",
|
||||
"name": "graphql.parse",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541851834000000",
|
||||
"endTimeUnixNano": "1751541851834925292",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query UserReview($id:ID!){user(id:$id){id reviews{id product{upc name weight}body}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "UserReview"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "395970847a47f14794c1c1af2499eb94",
|
||||
"spanId": "2225b5183449d944",
|
||||
"parentSpanId": "608141aa264650c6",
|
||||
"name": "graphql.validate",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541851836000000",
|
||||
"endTimeUnixNano": "1751541851838385167",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query UserReview($id:ID!){user(id:$id){id reviews{id product{upc name weight}body}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "UserReview"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "395970847a47f14794c1c1af2499eb94",
|
||||
"spanId": "22a0be695f656a93",
|
||||
"parentSpanId": "608141aa264650c6",
|
||||
"name": "graphql.context",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541851838000000",
|
||||
"endTimeUnixNano": "1751541851838188625",
|
||||
"attributes": [],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"resourceSpans": [
|
||||
{
|
||||
"resource": {
|
||||
"attributes": [
|
||||
{
|
||||
"key": "service.name",
|
||||
"value": {
|
||||
"stringValue": "hive-gateway"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "service.version",
|
||||
"value": {
|
||||
"stringValue": "5.13.4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0
|
||||
},
|
||||
"scopeSpans": [
|
||||
{
|
||||
"scope": {
|
||||
"name": "gateway"
|
||||
},
|
||||
"spans": [
|
||||
{
|
||||
"traceId": "395970847a47f14794c1c1af2499eb94",
|
||||
"spanId": "f567c64730c382b4",
|
||||
"parentSpanId": "49624b099fea77b7",
|
||||
"name": "http.fetch",
|
||||
"kind": 3,
|
||||
"startTimeUnixNano": "1751541851841000000",
|
||||
"endTimeUnixNano": "1751541852120716584",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "http.method",
|
||||
"value": {
|
||||
"stringValue": "POST"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.url",
|
||||
"value": {
|
||||
"stringValue": "https://federation-demo.theguild.workers.dev/users"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "net.host.name",
|
||||
"value": {
|
||||
"stringValue": "federation-demo.theguild.workers.dev"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.host",
|
||||
"value": {
|
||||
"stringValue": "federation-demo.theguild.workers.dev"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.route",
|
||||
"value": {
|
||||
"stringValue": "/users"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.scheme",
|
||||
"value": {
|
||||
"stringValue": "https:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.status_code",
|
||||
"value": {
|
||||
"intValue": 200
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 1
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "395970847a47f14794c1c1af2499eb94",
|
||||
"spanId": "49624b099fea77b7",
|
||||
"parentSpanId": "c0d8db7a0d7cd325",
|
||||
"name": "subgraph.execute (users)",
|
||||
"kind": 3,
|
||||
"startTimeUnixNano": "1751541851840000000",
|
||||
"endTimeUnixNano": "1751541852121396834",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "UserReview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query UserReview($id:ID!){__typename user(id:$id){__typename id id}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "gateway.upstream.subgraph.name",
|
||||
"value": {
|
||||
"stringValue": "users"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.gateway.upstream.subgraph.name",
|
||||
"value": {
|
||||
"stringValue": "users"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query UserReview($id:ID!){__typename user(id:$id){__typename id id}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "UserReview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "395970847a47f14794c1c1af2499eb94",
|
||||
"spanId": "c0d8db7a0d7cd325",
|
||||
"parentSpanId": "608141aa264650c6",
|
||||
"name": "graphql.execute",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541851838000000",
|
||||
"endTimeUnixNano": "1751541852121068708",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "UserReview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query UserReview($id:ID!){user(id:$id){id reviews{id product{upc name weight}body}}}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "395970847a47f14794c1c1af2499eb94",
|
||||
"spanId": "608141aa264650c6",
|
||||
"parentSpanId": "9bacab5be0803b57",
|
||||
"name": "graphql.operation UserReview",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541851834000000",
|
||||
"endTimeUnixNano": "1751541852121572417",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query UserReview($id:ID!){user(id:$id){id reviews{id product{upc name weight}body}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "UserReview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "gateway.cache.response_cache",
|
||||
"value": {
|
||||
"stringValue": "miss"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "395970847a47f14794c1c1af2499eb94",
|
||||
"spanId": "9bacab5be0803b57",
|
||||
"name": "query UserReview",
|
||||
"kind": 2,
|
||||
"startTimeUnixNano": "1751541851832000000",
|
||||
"endTimeUnixNano": "1751541852121678542",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "http.method",
|
||||
"value": {
|
||||
"stringValue": "POST"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.url",
|
||||
"value": {
|
||||
"stringValue": "http://localhost:4000/graphql"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.route",
|
||||
"value": {
|
||||
"stringValue": "/graphql"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.scheme",
|
||||
"value": {
|
||||
"stringValue": "http:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "net.host.name",
|
||||
"value": {
|
||||
"stringValue": "localhost"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.host",
|
||||
"value": {
|
||||
"stringValue": "localhost:4000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.user_agent",
|
||||
"value": {
|
||||
"stringValue": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.gateway.operation.subgraph.names",
|
||||
"value": {
|
||||
"arrayValue": {
|
||||
"values": [{ "stringValue": "users" }]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "UserReview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query UserReview($id:ID!){user(id:$id){id reviews{id product{upc name weight}body}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.graphql.error.count",
|
||||
"value": {
|
||||
"intValue": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.status_code",
|
||||
"value": {
|
||||
"intValue": 200
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "gateway.cache.response_cache",
|
||||
"value": {
|
||||
"stringValue": "miss"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.graphql",
|
||||
"value": {
|
||||
"boolValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 1
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
788
scripts/seed-traces/sample-user-review.json
Normal file
788
scripts/seed-traces/sample-user-review.json
Normal file
|
|
@ -0,0 +1,788 @@
|
|||
[
|
||||
{
|
||||
"resourceSpans": [
|
||||
{
|
||||
"resource": {
|
||||
"attributes": [
|
||||
{
|
||||
"key": "service.name",
|
||||
"value": {
|
||||
"stringValue": "hive-gateway"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "service.version",
|
||||
"value": {
|
||||
"stringValue": "5.13.4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0
|
||||
},
|
||||
"scopeSpans": [
|
||||
{
|
||||
"scope": {
|
||||
"name": "gateway"
|
||||
},
|
||||
"spans": [
|
||||
{
|
||||
"traceId": "8f7b3cc381c16ab7a1ff9f5e95b7076b",
|
||||
"spanId": "8e7742a266dfa50e",
|
||||
"parentSpanId": "b0d7fd9d6fc79c8e",
|
||||
"name": "graphql.parse",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541997641000000",
|
||||
"endTimeUnixNano": "1751541997641163583",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query UserReview($id:ID!){user(id:$id){id reviews{id product{upc name weight}body}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "UserReview"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "8f7b3cc381c16ab7a1ff9f5e95b7076b",
|
||||
"spanId": "88da58c0b0307abc",
|
||||
"parentSpanId": "b0d7fd9d6fc79c8e",
|
||||
"name": "graphql.validate",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541997642000000",
|
||||
"endTimeUnixNano": "1751541997642059625",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query UserReview($id:ID!){user(id:$id){id reviews{id product{upc name weight}body}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "UserReview"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "8f7b3cc381c16ab7a1ff9f5e95b7076b",
|
||||
"spanId": "8b990366087e0267",
|
||||
"parentSpanId": "b0d7fd9d6fc79c8e",
|
||||
"name": "graphql.context",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541997642000000",
|
||||
"endTimeUnixNano": "1751541997642070875",
|
||||
"attributes": [],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"resourceSpans": [
|
||||
{
|
||||
"resource": {
|
||||
"attributes": [
|
||||
{
|
||||
"key": "service.name",
|
||||
"value": {
|
||||
"stringValue": "hive-gateway"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "service.version",
|
||||
"value": {
|
||||
"stringValue": "5.13.4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0
|
||||
},
|
||||
"scopeSpans": [
|
||||
{
|
||||
"scope": {
|
||||
"name": "gateway"
|
||||
},
|
||||
"spans": [
|
||||
{
|
||||
"traceId": "8f7b3cc381c16ab7a1ff9f5e95b7076b",
|
||||
"spanId": "a082a6e591be0dc0",
|
||||
"parentSpanId": "034aa685d1130fa9",
|
||||
"name": "http.fetch",
|
||||
"kind": 3,
|
||||
"startTimeUnixNano": "1751541997644000000",
|
||||
"endTimeUnixNano": "1751541997808645667",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "http.method",
|
||||
"value": {
|
||||
"stringValue": "POST"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.url",
|
||||
"value": {
|
||||
"stringValue": "https://federation-demo.theguild.workers.dev/users"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "net.host.name",
|
||||
"value": {
|
||||
"stringValue": "federation-demo.theguild.workers.dev"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.host",
|
||||
"value": {
|
||||
"stringValue": "federation-demo.theguild.workers.dev"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.route",
|
||||
"value": {
|
||||
"stringValue": "/users"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.scheme",
|
||||
"value": {
|
||||
"stringValue": "https:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.status_code",
|
||||
"value": {
|
||||
"intValue": 200
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 1
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "8f7b3cc381c16ab7a1ff9f5e95b7076b",
|
||||
"spanId": "034aa685d1130fa9",
|
||||
"parentSpanId": "4709e78906f7ee93",
|
||||
"name": "subgraph.execute (users)",
|
||||
"kind": 3,
|
||||
"startTimeUnixNano": "1751541997643000000",
|
||||
"endTimeUnixNano": "1751541997808068667",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "UserReview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query UserReview($id:ID!){__typename user(id:$id){__typename id id}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "gateway.upstream.subgraph.name",
|
||||
"value": {
|
||||
"stringValue": "users"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.gateway.upstream.subgraph.name",
|
||||
"value": {
|
||||
"stringValue": "users"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query UserReview($id:ID!){__typename user(id:$id){__typename id id}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "UserReview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"resourceSpans": [
|
||||
{
|
||||
"resource": {
|
||||
"attributes": [
|
||||
{
|
||||
"key": "service.name",
|
||||
"value": {
|
||||
"stringValue": "hive-gateway"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "service.version",
|
||||
"value": {
|
||||
"stringValue": "5.13.4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0
|
||||
},
|
||||
"scopeSpans": [
|
||||
{
|
||||
"scope": {
|
||||
"name": "gateway"
|
||||
},
|
||||
"spans": [
|
||||
{
|
||||
"traceId": "8f7b3cc381c16ab7a1ff9f5e95b7076b",
|
||||
"spanId": "0cbf86b7f4e5a251",
|
||||
"parentSpanId": "879cef6dd0df538d",
|
||||
"name": "http.fetch",
|
||||
"kind": 3,
|
||||
"startTimeUnixNano": "1751541997811000000",
|
||||
"endTimeUnixNano": "1751541997859345000",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "http.method",
|
||||
"value": {
|
||||
"stringValue": "POST"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.url",
|
||||
"value": {
|
||||
"stringValue": "https://federation-demo.theguild.workers.dev/reviews"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "net.host.name",
|
||||
"value": {
|
||||
"stringValue": "federation-demo.theguild.workers.dev"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.host",
|
||||
"value": {
|
||||
"stringValue": "federation-demo.theguild.workers.dev"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.route",
|
||||
"value": {
|
||||
"stringValue": "/reviews"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.scheme",
|
||||
"value": {
|
||||
"stringValue": "https:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.status_code",
|
||||
"value": {
|
||||
"intValue": 200
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 1
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "8f7b3cc381c16ab7a1ff9f5e95b7076b",
|
||||
"spanId": "879cef6dd0df538d",
|
||||
"parentSpanId": "4709e78906f7ee93",
|
||||
"name": "subgraph.execute (reviews)",
|
||||
"kind": 3,
|
||||
"startTimeUnixNano": "1751541997810000000",
|
||||
"endTimeUnixNano": "1751541997859110458",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "UserReview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query UserReview($representations:[_Any!]!){__typename _entities(representations:$representations){__typename ...on User{id reviews{__typename id id product{__typename upc upc}body}}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "gateway.upstream.subgraph.name",
|
||||
"value": {
|
||||
"stringValue": "reviews"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.gateway.upstream.subgraph.name",
|
||||
"value": {
|
||||
"stringValue": "reviews"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query UserReview($representations:[_Any!]!){__typename _entities(representations:$representations){__typename ...on User{id reviews{__typename id id product{__typename upc upc}body}}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "UserReview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"resourceSpans": [
|
||||
{
|
||||
"resource": {
|
||||
"attributes": [
|
||||
{
|
||||
"key": "service.name",
|
||||
"value": {
|
||||
"stringValue": "hive-gateway"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "service.version",
|
||||
"value": {
|
||||
"stringValue": "5.13.4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0
|
||||
},
|
||||
"scopeSpans": [
|
||||
{
|
||||
"scope": {
|
||||
"name": "gateway"
|
||||
},
|
||||
"spans": [
|
||||
{
|
||||
"traceId": "8f7b3cc381c16ab7a1ff9f5e95b7076b",
|
||||
"spanId": "242481d587116eb4",
|
||||
"parentSpanId": "37bb599ff9427274",
|
||||
"name": "http.fetch",
|
||||
"kind": 3,
|
||||
"startTimeUnixNano": "1751541997861000000",
|
||||
"endTimeUnixNano": "1751541997902277625",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "http.method",
|
||||
"value": {
|
||||
"stringValue": "POST"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.url",
|
||||
"value": {
|
||||
"stringValue": "https://federation-demo.theguild.workers.dev/products"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "net.host.name",
|
||||
"value": {
|
||||
"stringValue": "federation-demo.theguild.workers.dev"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.host",
|
||||
"value": {
|
||||
"stringValue": "federation-demo.theguild.workers.dev"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.route",
|
||||
"value": {
|
||||
"stringValue": "/products"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.scheme",
|
||||
"value": {
|
||||
"stringValue": "https:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.status_code",
|
||||
"value": {
|
||||
"intValue": 200
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 1
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "8f7b3cc381c16ab7a1ff9f5e95b7076b",
|
||||
"spanId": "37bb599ff9427274",
|
||||
"parentSpanId": "4709e78906f7ee93",
|
||||
"name": "subgraph.execute (products)",
|
||||
"kind": 3,
|
||||
"startTimeUnixNano": "1751541997861000000",
|
||||
"endTimeUnixNano": "1751541997903041792",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "UserReview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query UserReview($representations:[_Any!]!){__typename _entities(representations:$representations){__typename ...on Product{upc name weight}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "gateway.upstream.subgraph.name",
|
||||
"value": {
|
||||
"stringValue": "products"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.gateway.upstream.subgraph.name",
|
||||
"value": {
|
||||
"stringValue": "products"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query UserReview($representations:[_Any!]!){__typename _entities(representations:$representations){__typename ...on Product{upc name weight}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "UserReview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "8f7b3cc381c16ab7a1ff9f5e95b7076b",
|
||||
"spanId": "4709e78906f7ee93",
|
||||
"parentSpanId": "b0d7fd9d6fc79c8e",
|
||||
"name": "graphql.execute",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541997642000000",
|
||||
"endTimeUnixNano": "1751541997903080750",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "UserReview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query UserReview($id:ID!){user(id:$id){id reviews{id product{upc name weight}body}}}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "8f7b3cc381c16ab7a1ff9f5e95b7076b",
|
||||
"spanId": "b0d7fd9d6fc79c8e",
|
||||
"parentSpanId": "bbc7518b553093df",
|
||||
"name": "graphql.operation UserReview",
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": "1751541997641000000",
|
||||
"endTimeUnixNano": "1751541997902778250",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query UserReview($id:ID!){user(id:$id){id reviews{id product{upc name weight}body}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "UserReview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "gateway.cache.response_cache",
|
||||
"value": {
|
||||
"stringValue": "miss"
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 0
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
},
|
||||
{
|
||||
"traceId": "8f7b3cc381c16ab7a1ff9f5e95b7076b",
|
||||
"spanId": "bbc7518b553093df",
|
||||
"name": "query UserReview",
|
||||
"kind": 2,
|
||||
"startTimeUnixNano": "1751541997638000000",
|
||||
"endTimeUnixNano": "1751541997902695084",
|
||||
"attributes": [
|
||||
{
|
||||
"key": "http.method",
|
||||
"value": {
|
||||
"stringValue": "POST"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.url",
|
||||
"value": {
|
||||
"stringValue": "http://localhost:4000/graphql"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.route",
|
||||
"value": {
|
||||
"stringValue": "/graphql"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.scheme",
|
||||
"value": {
|
||||
"stringValue": "http:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "net.host.name",
|
||||
"value": {
|
||||
"stringValue": "localhost"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.host",
|
||||
"value": {
|
||||
"stringValue": "localhost:4000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.user_agent",
|
||||
"value": {
|
||||
"stringValue": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.gateway.operation.subgraph.names",
|
||||
"value": {
|
||||
"arrayValue": {
|
||||
"values": [
|
||||
{ "stringValue": "users" },
|
||||
{ "stringValue": "reviews" },
|
||||
{ "stringValue": "products" }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.type",
|
||||
"value": {
|
||||
"stringValue": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.operation.name",
|
||||
"value": {
|
||||
"stringValue": "UserReview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "graphql.document",
|
||||
"value": {
|
||||
"stringValue": "query UserReview($id:ID!){user(id:$id){id reviews{id product{upc name weight}body}}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.graphql.error.count",
|
||||
"value": {
|
||||
"intValue": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "http.status_code",
|
||||
"value": {
|
||||
"intValue": 200
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "gateway.cache.response_cache",
|
||||
"value": {
|
||||
"stringValue": "miss"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "hive.graphql",
|
||||
"value": {
|
||||
"boolValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"droppedAttributesCount": 0,
|
||||
"events": [],
|
||||
"droppedEventsCount": 0,
|
||||
"status": {
|
||||
"code": 1
|
||||
},
|
||||
"links": [],
|
||||
"droppedLinksCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
817
scripts/seed-traces/seed-traces.mts
Normal file
817
scripts/seed-traces/seed-traces.mts
Normal file
|
|
@ -0,0 +1,817 @@
|
|||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as date from 'date-fns';
|
||||
import * as immer from 'immer';
|
||||
import * as faker from '@faker-js/faker';
|
||||
|
||||
// This is just copy pasted here to infer the TS type
|
||||
// Please feel free to collapse this section.
|
||||
const reference = [
|
||||
{
|
||||
resourceSpans: [
|
||||
{
|
||||
resource: {
|
||||
attributes: [
|
||||
{
|
||||
key: 'service.name',
|
||||
value: {
|
||||
stringValue: 'hive-gateway',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'service.version',
|
||||
value: {
|
||||
stringValue: '5.13.4',
|
||||
},
|
||||
},
|
||||
],
|
||||
droppedAttributesCount: 0,
|
||||
},
|
||||
scopeSpans: [
|
||||
{
|
||||
scope: {
|
||||
name: 'gateway',
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
traceId: 'd72a6b878beb3fee4287243c1c51ebaa',
|
||||
spanId: 'bf7578eaabcd9963',
|
||||
name: 'GET /graphql',
|
||||
kind: 2,
|
||||
startTimeUnixNano: '1751541577026000000',
|
||||
endTimeUnixNano: '1751541577027457584',
|
||||
attributes: [
|
||||
{
|
||||
key: 'http.method',
|
||||
value: {
|
||||
stringValue: 'GET',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'http.url',
|
||||
value: {
|
||||
stringValue:
|
||||
'http://localhost:4000/graphql?query=query+TopProductsOverview+%7B%0A++topProducts+%7B%0A++++name%0A++++upc%0A++++price%0A++++weight%0A++++reviews+%7B%0A++++++id%0A++++++body%0A++++++author+%7B%0A++++++++id%0A++++++++username%0A++++++++name%0A++++++%7D%0A++++%7D%0A++%7D%0A%7D',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'http.route',
|
||||
value: {
|
||||
stringValue: '/graphql',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'http.scheme',
|
||||
value: {
|
||||
stringValue: 'http:',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'net.host.name',
|
||||
value: {
|
||||
stringValue: 'localhost',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'http.host',
|
||||
value: {
|
||||
stringValue: 'localhost:4000',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'http.user_agent',
|
||||
value: {
|
||||
stringValue:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'http.status_code',
|
||||
value: {
|
||||
intValue: 200,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'gateway.cache.response_cache',
|
||||
value: {
|
||||
stringValue: 'miss',
|
||||
},
|
||||
},
|
||||
],
|
||||
droppedAttributesCount: 0,
|
||||
events: [],
|
||||
droppedEventsCount: 0,
|
||||
status: {
|
||||
code: 1,
|
||||
},
|
||||
links: [],
|
||||
droppedLinksCount: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
resourceSpans: [
|
||||
{
|
||||
resource: {
|
||||
attributes: [
|
||||
{
|
||||
key: 'service.name',
|
||||
value: {
|
||||
stringValue: 'hive-gateway',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'service.version',
|
||||
value: {
|
||||
stringValue: '5.13.4',
|
||||
},
|
||||
},
|
||||
],
|
||||
droppedAttributesCount: 0,
|
||||
},
|
||||
scopeSpans: [
|
||||
{
|
||||
scope: {
|
||||
name: 'gateway',
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
traceId: '6045da1fe92d42a36f0ffdcca55efa46',
|
||||
spanId: '17fd8c5b3d731702',
|
||||
parentSpanId: 'b763ec1ad8a305ca',
|
||||
name: 'http.fetch',
|
||||
kind: 3,
|
||||
startTimeUnixNano: '1751541577186000000',
|
||||
endTimeUnixNano: '1751541577337256209',
|
||||
attributes: [],
|
||||
droppedAttributesCount: 0,
|
||||
events: [],
|
||||
droppedEventsCount: 0,
|
||||
status: {
|
||||
code: 0,
|
||||
},
|
||||
links: [],
|
||||
droppedLinksCount: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
resourceSpans: [
|
||||
{
|
||||
resource: {
|
||||
attributes: [
|
||||
{
|
||||
key: 'service.name',
|
||||
value: {
|
||||
stringValue: 'hive-gateway',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'service.version',
|
||||
value: {
|
||||
stringValue: '5.13.4',
|
||||
},
|
||||
},
|
||||
],
|
||||
droppedAttributesCount: 0,
|
||||
},
|
||||
scopeSpans: [
|
||||
{
|
||||
scope: {
|
||||
name: 'gateway',
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
traceId: '6045da1fe92d42a36f0ffdcca55efa46',
|
||||
spanId: 'b763ec1ad8a305ca',
|
||||
name: 'gateway.initialization',
|
||||
kind: 1,
|
||||
startTimeUnixNano: '1751541567757845981',
|
||||
endTimeUnixNano: '1751541577382000000',
|
||||
attributes: [
|
||||
{
|
||||
key: 'http.method',
|
||||
value: {
|
||||
stringValue: 'GET',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'http.url',
|
||||
value: {
|
||||
stringValue:
|
||||
'http://host.docker.internal:3001/artifacts/v1/219fedb6-8a9b-44d7-929f-b0b16750653c/supergraph',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'net.host.name',
|
||||
value: {
|
||||
stringValue: 'host.docker.internal',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'http.host',
|
||||
value: {
|
||||
stringValue: 'host.docker.internal:3001',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'http.route',
|
||||
value: {
|
||||
stringValue: '/artifacts/v1/219fedb6-8a9b-44d7-929f-b0b16750653c/supergraph',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'http.scheme',
|
||||
value: {
|
||||
stringValue: 'http:',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'http.status_code',
|
||||
value: {
|
||||
intValue: 200,
|
||||
},
|
||||
},
|
||||
],
|
||||
droppedAttributesCount: 0,
|
||||
events: [],
|
||||
droppedEventsCount: 0,
|
||||
status: {
|
||||
code: 1,
|
||||
},
|
||||
links: [],
|
||||
droppedLinksCount: 0,
|
||||
},
|
||||
{
|
||||
traceId: 'f42cb37f3811f650f06ca078e7c6625f',
|
||||
spanId: 'c55a1a27cfe0a563',
|
||||
parentSpanId: '6df96e8ebb5f3dd7',
|
||||
name: 'graphql.parse',
|
||||
kind: 1,
|
||||
startTimeUnixNano: '1751541577384000000',
|
||||
endTimeUnixNano: '1751541577385261166',
|
||||
attributes: [
|
||||
{
|
||||
key: 'graphql.document',
|
||||
value: {
|
||||
stringValue:
|
||||
'query IntrospectionQuery{__schema{description queryType{name kind}mutationType{name kind}subscriptionType{name kind}types{...FullType}directives{name description locations args(includeDeprecated:true){...InputValue}}}}fragment FullType on __Type{kind name description fields(includeDeprecated:true){name description args(includeDeprecated:true){...InputValue}type{...TypeRef}isDeprecated deprecationReason}inputFields(includeDeprecated:true){...InputValue}interfaces{...TypeRef}enumValues(includeDeprecated:true){name description isDeprecated deprecationReason}possibleTypes{...TypeRef}}fragment InputValue on __InputValue{name description type{...TypeRef}defaultValue isDeprecated deprecationReason}fragment TypeRef on __Type{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name}}}}}}}}}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'graphql.operation.name',
|
||||
value: {
|
||||
stringValue: 'IntrospectionQuery',
|
||||
},
|
||||
},
|
||||
],
|
||||
droppedAttributesCount: 0,
|
||||
events: [],
|
||||
droppedEventsCount: 0,
|
||||
status: {
|
||||
code: 0,
|
||||
},
|
||||
links: [],
|
||||
droppedLinksCount: 0,
|
||||
},
|
||||
{
|
||||
traceId: 'f42cb37f3811f650f06ca078e7c6625f',
|
||||
spanId: 'ca97bb80da3c0dbe',
|
||||
parentSpanId: '6df96e8ebb5f3dd7',
|
||||
name: 'graphql.validate',
|
||||
kind: 1,
|
||||
startTimeUnixNano: '1751541577385000000',
|
||||
endTimeUnixNano: '1751541577397343958',
|
||||
attributes: [
|
||||
{
|
||||
key: 'graphql.document',
|
||||
value: {
|
||||
stringValue:
|
||||
'query IntrospectionQuery{__schema{description queryType{name kind}mutationType{name kind}subscriptionType{name kind}types{...FullType}directives{name description locations args(includeDeprecated:true){...InputValue}}}}fragment FullType on __Type{kind name description fields(includeDeprecated:true){name description args(includeDeprecated:true){...InputValue}type{...TypeRef}isDeprecated deprecationReason}inputFields(includeDeprecated:true){...InputValue}interfaces{...TypeRef}enumValues(includeDeprecated:true){name description isDeprecated deprecationReason}possibleTypes{...TypeRef}}fragment InputValue on __InputValue{name description type{...TypeRef}defaultValue isDeprecated deprecationReason}fragment TypeRef on __Type{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name}}}}}}}}}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'graphql.operation.name',
|
||||
value: {
|
||||
stringValue: 'IntrospectionQuery',
|
||||
},
|
||||
},
|
||||
],
|
||||
droppedAttributesCount: 0,
|
||||
events: [],
|
||||
droppedEventsCount: 0,
|
||||
status: {
|
||||
code: 0,
|
||||
},
|
||||
links: [],
|
||||
droppedLinksCount: 0,
|
||||
},
|
||||
{
|
||||
traceId: 'f42cb37f3811f650f06ca078e7c6625f',
|
||||
spanId: '697d38f300df2167',
|
||||
parentSpanId: '6df96e8ebb5f3dd7',
|
||||
name: 'graphql.context',
|
||||
kind: 1,
|
||||
startTimeUnixNano: '1751541577398000000',
|
||||
endTimeUnixNano: '1751541577399414208',
|
||||
attributes: [],
|
||||
droppedAttributesCount: 0,
|
||||
events: [],
|
||||
droppedEventsCount: 0,
|
||||
status: {
|
||||
code: 0,
|
||||
},
|
||||
links: [],
|
||||
droppedLinksCount: 0,
|
||||
},
|
||||
{
|
||||
traceId: 'f42cb37f3811f650f06ca078e7c6625f',
|
||||
spanId: 'f479d33b7320cd0f',
|
||||
parentSpanId: '6df96e8ebb5f3dd7',
|
||||
name: 'graphql.execute',
|
||||
kind: 1,
|
||||
startTimeUnixNano: '1751541577400000000',
|
||||
endTimeUnixNano: '1751541577410081959',
|
||||
attributes: [
|
||||
{
|
||||
key: 'graphql.operation.type',
|
||||
value: {
|
||||
stringValue: 'query',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'graphql.operation.name',
|
||||
value: {
|
||||
stringValue: 'IntrospectionQuery',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'graphql.document',
|
||||
value: {
|
||||
stringValue:
|
||||
'query IntrospectionQuery{__schema{description queryType{name kind}mutationType{name kind}subscriptionType{name kind}types{...FullType}directives{name description locations args(includeDeprecated:true){...InputValue}}}}fragment FullType on __Type{kind name description fields(includeDeprecated:true){name description args(includeDeprecated:true){...InputValue}type{...TypeRef}isDeprecated deprecationReason}inputFields(includeDeprecated:true){...InputValue}interfaces{...TypeRef}enumValues(includeDeprecated:true){name description isDeprecated deprecationReason}possibleTypes{...TypeRef}}fragment InputValue on __InputValue{name description type{...TypeRef}defaultValue isDeprecated deprecationReason}fragment TypeRef on __Type{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name}}}}}}}}}}',
|
||||
},
|
||||
},
|
||||
],
|
||||
droppedAttributesCount: 0,
|
||||
events: [],
|
||||
droppedEventsCount: 0,
|
||||
status: {
|
||||
code: 0,
|
||||
},
|
||||
links: [],
|
||||
droppedLinksCount: 0,
|
||||
},
|
||||
{
|
||||
traceId: 'f42cb37f3811f650f06ca078e7c6625f',
|
||||
spanId: '6df96e8ebb5f3dd7',
|
||||
parentSpanId: 'af8929e2e40e00af',
|
||||
name: 'graphql.operation IntrospectionQuery',
|
||||
kind: 1,
|
||||
startTimeUnixNano: '1751541577382000000',
|
||||
endTimeUnixNano: '1751541577409557042',
|
||||
attributes: [
|
||||
{
|
||||
key: 'graphql.document',
|
||||
value: {
|
||||
stringValue:
|
||||
'query IntrospectionQuery{__schema{description queryType{name kind}mutationType{name kind}subscriptionType{name kind}types{...FullType}directives{name description locations args(includeDeprecated:true){...InputValue}}}}fragment FullType on __Type{kind name description fields(includeDeprecated:true){name description args(includeDeprecated:true){...InputValue}type{...TypeRef}isDeprecated deprecationReason}inputFields(includeDeprecated:true){...InputValue}interfaces{...TypeRef}enumValues(includeDeprecated:true){name description isDeprecated deprecationReason}possibleTypes{...TypeRef}}fragment InputValue on __InputValue{name description type{...TypeRef}defaultValue isDeprecated deprecationReason}fragment TypeRef on __Type{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name}}}}}}}}}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'graphql.operation.name',
|
||||
value: {
|
||||
stringValue: 'IntrospectionQuery',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'graphql.operation.type',
|
||||
value: {
|
||||
stringValue: 'query',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'gateway.cache.response_cache',
|
||||
value: {
|
||||
stringValue: 'miss',
|
||||
},
|
||||
},
|
||||
],
|
||||
droppedAttributesCount: 0,
|
||||
events: [],
|
||||
droppedEventsCount: 0,
|
||||
status: {
|
||||
code: 0,
|
||||
},
|
||||
links: [],
|
||||
droppedLinksCount: 0,
|
||||
},
|
||||
{
|
||||
traceId: 'f42cb37f3811f650f06ca078e7c6625f',
|
||||
spanId: 'af8929e2e40e00af',
|
||||
name: 'query IntrospectionQuery',
|
||||
kind: 2,
|
||||
startTimeUnixNano: '1751541577154000000',
|
||||
endTimeUnixNano: '1751541577412387709',
|
||||
attributes: [
|
||||
{
|
||||
key: 'http.method',
|
||||
value: {
|
||||
stringValue: 'POST',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'http.url',
|
||||
value: {
|
||||
stringValue: 'http://localhost:4000/graphql',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'http.route',
|
||||
value: {
|
||||
stringValue: '/graphql',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'http.scheme',
|
||||
value: {
|
||||
stringValue: 'http:',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'net.host.name',
|
||||
value: {
|
||||
stringValue: 'localhost',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'http.host',
|
||||
value: {
|
||||
stringValue: 'localhost:4000',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'http.user_agent',
|
||||
value: {
|
||||
stringValue:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'graphql.operation.type',
|
||||
value: {
|
||||
stringValue: 'query',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'graphql.operation.name',
|
||||
value: {
|
||||
stringValue: 'IntrospectionQuery',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'graphql.document',
|
||||
value: {
|
||||
stringValue:
|
||||
'query IntrospectionQuery{__schema{description queryType{name kind}mutationType{name kind}subscriptionType{name kind}types{...FullType}directives{name description locations args(includeDeprecated:true){...InputValue}}}}fragment FullType on __Type{kind name description fields(includeDeprecated:true){name description args(includeDeprecated:true){...InputValue}type{...TypeRef}isDeprecated deprecationReason}inputFields(includeDeprecated:true){...InputValue}interfaces{...TypeRef}enumValues(includeDeprecated:true){name description isDeprecated deprecationReason}possibleTypes{...TypeRef}}fragment InputValue on __InputValue{name description type{...TypeRef}defaultValue isDeprecated deprecationReason}fragment TypeRef on __Type{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name}}}}}}}}}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'hive.graphql.error.count',
|
||||
value: {
|
||||
intValue: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'hive.graphql.error.codes',
|
||||
value: {
|
||||
arrayValue: {
|
||||
values: [{ stringValue: 'ERR_NOT_FOUND' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'http.status_code',
|
||||
value: {
|
||||
intValue: 200,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'gateway.cache.response_cache',
|
||||
value: {
|
||||
stringValue: 'miss',
|
||||
},
|
||||
},
|
||||
],
|
||||
droppedAttributesCount: 0,
|
||||
events: [
|
||||
{
|
||||
attributes: [
|
||||
{
|
||||
key: 'exception.type',
|
||||
value: {
|
||||
stringValue: 'GraphQLError',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'exception.message',
|
||||
value: {
|
||||
stringValue: 'Variable "$id" of required type "ID!" was not provided.',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'exception.stacktrace',
|
||||
value: {
|
||||
stringValue:
|
||||
'GraphQLError: Variable "$id" of required type "ID!" was not provided.\n at createGraphQLError (file:///node_modules/.chunk/abortSignalAny-BG8Lg0X_.mjs:29:12)\n at coerceVariableValues (file:///node_modules/.chunk/normalizedExecutor-C4nYkuV1.mjs:566:25)\n at getVariableValues (file:///node_modules/.chunk/normalizedExecutor-C4nYkuV1.mjs:533:25)\n at buildExecutionContext (file:///node_modules/.chunk/normalizedExecutor-C4nYkuV1.mjs:746:35)\n at execute (file:///node_modules/.chunk/normalizedExecutor-C4nYkuV1.mjs:615:24)\n at file:///node_modules/.chunk/normalizedExecutor-C4nYkuV1.mjs:1960:37\n at Promise.then (file:///node_modules/.chunk/index-DYl9M8N-.mjs:31:40)\n at handleMaybePromise (file:///node_modules/.chunk/index-DYl9M8N-.mjs:10:33)\n at normalizedExecutor (file:///node_modules/.chunk/normalizedExecutor-C4nYkuV1.mjs:1960:12)\n at _4.args (file:///node_modules/.chunk/use-engine-CPlZaEw6.mjs:580:17)',
|
||||
},
|
||||
},
|
||||
],
|
||||
name: 'exception',
|
||||
timeUnixNano: '1751541923667636834',
|
||||
droppedAttributesCount: 0,
|
||||
},
|
||||
],
|
||||
droppedEventsCount: 0,
|
||||
status: {
|
||||
code: 1,
|
||||
},
|
||||
links: [],
|
||||
droppedLinksCount: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
type Reference = typeof reference;
|
||||
|
||||
function randomId(len = 32) {
|
||||
return crypto.randomBytes(len / 2).toString('hex');
|
||||
}
|
||||
|
||||
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<T>(arr: Array<T>) {
|
||||
return arr[getRandomIndex(arr.length)];
|
||||
}
|
||||
|
||||
const clientNames = ['sales-app', 'product-cron-sync', 'analytics-dashboard'];
|
||||
|
||||
const appVersions = new Map<string, Array<string>>();
|
||||
|
||||
for (const name of clientNames) {
|
||||
const versions = new Array<string>();
|
||||
for (let i = 0; i <= 10; i++) {
|
||||
versions.push(faker.faker.system.semver());
|
||||
}
|
||||
appVersions.set(name, versions);
|
||||
}
|
||||
|
||||
function generateRandomClient() {
|
||||
const name = randomArrayItem(clientNames);
|
||||
const version = randomArrayItem(appVersions.get(name)!);
|
||||
|
||||
return {
|
||||
name,
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
const errorCodes = [
|
||||
'INTERNAL_SERVER_ERROR',
|
||||
'ERR_UNAUTHENTICATED',
|
||||
'ERR_TEAPOD_NOT_FOUND',
|
||||
'ERR_NOT_FOUND',
|
||||
];
|
||||
|
||||
function getRandomErrorCodes() {
|
||||
if (Math.random() > 0.2) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return [randomArrayItem(errorCodes)];
|
||||
}
|
||||
|
||||
function mutate(currentTime: Date, reference: Reference) {
|
||||
const newTraceId = randomId();
|
||||
const newSpanIds = new Map<string, string>();
|
||||
|
||||
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: faker.faker.git.commitSha() },
|
||||
},
|
||||
);
|
||||
|
||||
const errors = getRandomErrorCodes();
|
||||
|
||||
if (errors) {
|
||||
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));
|
||||
}
|
||||
|
||||
const __dirname = import.meta.dirname;
|
||||
|
||||
const seedFolder = path.join(__dirname);
|
||||
|
||||
const files = await fs.readdir(seedFolder);
|
||||
|
||||
const references: Array<Reference> = [];
|
||||
|
||||
console.log(`Load samples from the '${seedFolder}' folder.`);
|
||||
for (const file of files) {
|
||||
if (!file.startsWith('sample-') || !file.endsWith('.json')) {
|
||||
console.log('Skip ' + file);
|
||||
continue;
|
||||
}
|
||||
console.log('Load' + file);
|
||||
references.push(JSON.parse(await fs.readFile(path.join(seedFolder, file), 'utf-8')));
|
||||
}
|
||||
|
||||
console.log(`Loaded ${references.length} references.`);
|
||||
|
||||
if (references.length === 0) {
|
||||
throw new Error('No references where found.');
|
||||
}
|
||||
|
||||
const USAGE_DAYS = process.env.USAGE_DAYS || '14';
|
||||
console.log(
|
||||
`Seeding usage for the last ${USAGE_DAYS} day(s). (Overwrite using the USAGE_DAYS environment variable)`,
|
||||
);
|
||||
const USAGE_INTERVAL = process.env.USAGE_INTERVAL || '1';
|
||||
console.log(
|
||||
`Seeding every ${USAGE_INTERVAL} minute(s). (Overwrite using the USAGE_INTERVAL environment variable)`,
|
||||
);
|
||||
|
||||
const otelEndpointUrl = process.env.OTEL_ENDPOINT || 'http://localhost:4318/v1/traces';
|
||||
console.log(
|
||||
`Endpoint: ${otelEndpointUrl}. (Overwrite using the OTEL_ENDPOINT environment variable)`,
|
||||
);
|
||||
|
||||
const HIVE_ORGANIZATION_ACCESS_TOKEN = process.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 = process.env.HIVE_TARGET_REF;
|
||||
if (!HIVE_TARGET_REF) {
|
||||
throw new Error('Environment variable HIVE_TARGET_REF is missing.');
|
||||
}
|
||||
|
||||
const intervalMinutes = parseInt(USAGE_INTERVAL, 10);
|
||||
const usageDays = parseInt(USAGE_DAYS, 10);
|
||||
const now = new Date();
|
||||
let currentDate = date.subDays(now, usageDays);
|
||||
|
||||
while (currentDate.getTime() < now.getTime()) {
|
||||
console.log(currentDate.toISOString());
|
||||
|
||||
const promises: Array<Promise<unknown>> = [];
|
||||
|
||||
for (const reference of references) {
|
||||
for (let i = randomInt(10); i > 0; i--) {
|
||||
const tracePayloads = createTrace(currentDate, reference);
|
||||
promises.push(
|
||||
...tracePayloads.map(body =>
|
||||
fetch(otelEndpointUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${HIVE_ORGANIZATION_ACCESS_TOKEN}`,
|
||||
'X-Hive-Target-Ref': HIVE_TARGET_REF,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}).then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error('Something went wrong');
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
currentDate = date.addMinutes(currentDate, intervalMinutes);
|
||||
}
|
||||
|
||||
function randomInt(until = 10) {
|
||||
return Math.floor(Math.random() * (until + 1));
|
||||
}
|
||||
Loading…
Reference in a new issue