Skip to content

Commit 9cf421a

Browse files
authored
feat(controlplane): add service-level tracing spans to Connect-RPC handlers (#2771)
1 parent 256913a commit 9cf421a

71 files changed

Lines changed: 1283 additions & 60 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

controlplane/.eslintrc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"extends": ["eslint-config-unjs", "plugin:require-extensions/recommended"],
3-
"plugins": ["require-extensions"],
3+
"plugins": ["require-extensions", "local-rules"],
44
"rules": {
55
"space-before-function-paren": 0,
66
"arrow-parens": 0,
@@ -25,5 +25,6 @@
2525
"no-useless-constructor": 0,
2626
"unicorn/prefer-ternary": 0,
2727
"unicorn/no-nested-ternary": 0,
28+
"local-rules/no-arrow-in-traced": "error",
2829
},
2930
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
'use strict';
2+
3+
function isTraced(node) {
4+
if (node.decorators && node.decorators.some((d) => d.expression && d.expression.name === 'traced')) {
5+
return true;
6+
}
7+
8+
let container = node.parent;
9+
let classNode = node;
10+
// export default class wraps the ClassDeclaration in ExportDefaultDeclaration
11+
if (container && container.type === 'ExportDefaultDeclaration') {
12+
classNode = container;
13+
container = container.parent;
14+
}
15+
if (!container || !container.body) {
16+
return false;
17+
}
18+
const siblings = container.body;
19+
const idx = siblings.indexOf(classNode);
20+
for (let i = idx + 1; i < siblings.length; i++) {
21+
const sibling = siblings[i];
22+
if (
23+
sibling.type === 'ExpressionStatement' &&
24+
sibling.expression &&
25+
sibling.expression.type === 'CallExpression' &&
26+
sibling.expression.callee &&
27+
sibling.expression.callee.name === 'traced' &&
28+
sibling.expression.arguments.length === 1 &&
29+
sibling.expression.arguments[0].name === node.id?.name
30+
) {
31+
return true;
32+
}
33+
if (sibling.type === 'ClassDeclaration' || sibling.type === 'FunctionDeclaration') {
34+
break;
35+
}
36+
}
37+
return false;
38+
}
39+
40+
module.exports = {
41+
'no-arrow-in-traced': {
42+
meta: {
43+
type: 'problem',
44+
docs: {
45+
description: 'Disallow arrow function class fields in @traced classes',
46+
},
47+
messages: {
48+
noArrowInTraced:
49+
'Arrow function class fields are not traced by the @traced decorator. Convert to a regular method.',
50+
},
51+
},
52+
create(context) {
53+
return {
54+
ClassDeclaration(node) {
55+
if (!isTraced(node)) {
56+
return;
57+
}
58+
for (const member of node.body.body) {
59+
if (
60+
member.type === 'PropertyDefinition' &&
61+
member.value &&
62+
member.value.type === 'ArrowFunctionExpression'
63+
) {
64+
context.report({ node: member, messageId: 'noArrowInTraced' });
65+
}
66+
}
67+
},
68+
};
69+
},
70+
},
71+
};

controlplane/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"lint": "eslint --cache --ext .ts,.mjs,.cjs . && prettier -c src",
2929
"lint:fix": "eslint --cache --fix --ext .ts,.mjs,.cjs . && pnpm format",
3030
"format": "prettier -w .",
31+
"sentry:spotlight": "spotlight",
3132
"migrate": "pnpm db:migrate && pnpm ch:migrate",
3233
"drizzle:up": "drizzle-kit up",
3334
"ch:down": "dbmate -d \"./clickhouse/migrations\" down",
@@ -106,6 +107,7 @@
106107
"@bufbuild/protobuf": "^1.9.0",
107108
"@bufbuild/protoc-gen-es": "^1.9.0",
108109
"@connectrpc/protoc-gen-connect-es": "^1.4.0",
110+
"@spotlightjs/spotlight": "^4.10.0",
109111
"@types/cookie": "^0.6.0",
110112
"@types/ejs": "^3.1.5",
111113
"@types/eslint": "^9.6.1",
@@ -118,6 +120,7 @@
118120
"del-cli": "^5.1.0",
119121
"drizzle-kit": "^0.26.2",
120122
"eslint-config-unjs": "^0.2.1",
123+
"eslint-plugin-local-rules": "^3.0.2",
121124
"eslint-plugin-require-extensions": "^0.1.3",
122125
"msw": "^2.2.11",
123126
"pino-pretty": "^10.3.1",

controlplane/src/core/auth-utils.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { OrganizationGroupRepository } from './repositories/OrganizationGroupRep
2626
import { DefaultNamespace, NamespaceRepository } from './repositories/NamespaceRepository.js';
2727
import Keycloak from './services/Keycloak.js';
2828
import { IPlatformWebhookService } from './webhooks/PlatformWebhookService.js';
29-
29+
import { traced } from './tracing.js';
3030
export type AuthUtilsOptions = {
3131
webBaseUrl: string;
3232
webErrorPath: string;
@@ -711,3 +711,5 @@ export default class AuthUtils {
711711
return 0;
712712
}
713713
}
714+
715+
traced(AuthUtils);

controlplane/src/core/blobstorage/dual.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { traced } from '../tracing.js';
12
import type { BlobObject, BlobStorage } from './index.js';
23

34
/**
@@ -6,6 +7,7 @@ import type { BlobObject, BlobStorage } from './index.js';
67
* - Writes and deletes go to both stores concurrently; both must succeed.
78
* - Reads try the primary first, falling back to the secondary on failure.
89
*/
10+
@traced
911
export class DualBlobStorage implements BlobStorage {
1012
constructor(
1113
private primary: BlobStorage,

controlplane/src/core/blobstorage/s3.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
PutObjectCommand,
88
S3Client,
99
} from '@aws-sdk/client-s3';
10+
import { traced } from '../tracing.js';
1011
import { BlobNotFoundError, BlobObject, type BlobStorage } from './index.js';
1112

1213
const maxConcurrency = 10; // Maximum number of concurrent operations
@@ -26,6 +27,7 @@ export interface S3BlobStorageConfig {
2627
/**
2728
* Stores objects in S3 given an S3Client and a bucket name
2829
*/
30+
@traced
2931
export class S3BlobStorage implements BlobStorage {
3032
private readonly useIndividualDeletes: boolean;
3133

controlplane/src/core/build-server.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Fastify, { FastifyBaseLogger } from 'fastify';
22
import { S3Client } from '@aws-sdk/client-s3';
33
import { fastifyConnectPlugin } from '@connectrpc/connect-fastify';
4+
import * as Sentry from '@sentry/node';
45
import { cors, createContextValues } from '@connectrpc/connect';
56
import fastifyCors from '@fastify/cors';
67
import { pino, stdTimeFunctions, LoggerOptions } from 'pino';
@@ -37,7 +38,13 @@ import { BillingRepository } from './repositories/BillingRepository.js';
3738
import { BillingService } from './services/BillingService.js';
3839
import { UserRepository } from './repositories/UserRepository.js';
3940
import { AIGraphReadmeQueue, createAIGraphReadmeWorker } from './workers/AIGraphReadmeWorker.js';
40-
import { fastifyLoggerId, createS3ClientConfig, extractS3BucketName, isGoogleCloudStorageUrl } from './util.js';
41+
import {
42+
fastifyLoggerId,
43+
sentrySpanId,
44+
createS3ClientConfig,
45+
extractS3BucketName,
46+
isGoogleCloudStorageUrl,
47+
} from './util.js';
4148
import { ApiKeyRepository } from './repositories/ApiKeyRepository.js';
4249
import { createDeleteOrganizationWorker, DeleteOrganizationQueue } from './workers/DeleteOrganizationWorker.js';
4350
import {
@@ -531,6 +538,16 @@ export default async function build(opts: BuildConfig) {
531538
keycloakRealm: opts.keycloak.realm,
532539
});
533540

541+
// Capture the active Sentry span in preHandler (where OTEL context is still available)
542+
// and store it on the request so Connect interceptors can use it as parentSpan.
543+
fastify.addHook('preHandler', (req, _reply, done) => {
544+
const span = Sentry.getActiveSpan();
545+
if (span) {
546+
(req.raw as any).__sentrySpan = span;
547+
}
548+
done();
549+
});
550+
534551
// Must be registered after custom fastify routes
535552
// Because it registers an all-catch route for connect handlers
536553

@@ -567,7 +584,16 @@ export default async function build(opts: BuildConfig) {
567584
cdnBaseUrl: opts.cdnBaseUrl,
568585
}),
569586
contextValues(req) {
570-
return createContextValues().set<FastifyBaseLogger>({ id: fastifyLoggerId, defaultValue: req.log }, req.log);
587+
const values = createContextValues().set<FastifyBaseLogger>(
588+
{ id: fastifyLoggerId, defaultValue: req.log },
589+
req.log,
590+
);
591+
// Read the parent span captured during the preHandler hook
592+
const parentSpan = (req.raw as any).__sentrySpan;
593+
if (parentSpan) {
594+
values.set({ id: sentrySpanId, defaultValue: undefined }, parentSpan);
595+
}
596+
return values;
571597
},
572598
logLevel: opts.logger.level as pino.LevelWithSilent,
573599
// Avoid compression for small requests
@@ -578,6 +604,18 @@ export default async function build(opts: BuildConfig) {
578604
// We go with 32MiB to avoid allocating too much memory for large requests
579605
writeMaxBytes: 32 * 1024 * 1024,
580606
acceptCompression: [compressionBrotli, compressionGzip],
607+
interceptors: [
608+
(next) => (req) => {
609+
const parentSpan = req.contextValues?.get({
610+
id: sentrySpanId,
611+
defaultValue: undefined,
612+
});
613+
if (parentSpan) {
614+
return Sentry.withActiveSpan(parentSpan, () => next(req));
615+
}
616+
return next(req);
617+
},
618+
],
581619
});
582620

583621
await fastify.register(fastifyGracefulShutdown, {

controlplane/src/core/clickhouse/client/ClickHouseClient.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import pkg from 'stream-json';
77

88
import { Observable, Subscriber } from 'rxjs';
99

10+
import { traced } from '../../tracing.js';
1011
import { ClickHouseCompressionMethod, ClickHouseDataFormat } from './enums/index.js';
1112

1213
import { ClickHouseClientOptions } from './interfaces/index.js';
@@ -16,6 +17,7 @@ const { Parser } = pkg;
1617
* ClickHouse Client
1718
* Most of the code is taken from https://github.com/depyronick/clickhouse-client
1819
*/
20+
@traced
1921
export class ClickHouseClient {
2022
/**
2123
* ClickHouse Endpoint without path and query

controlplane/src/core/composition/composer.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { CacheWarmerRepository } from '../repositories/CacheWarmerRepository.js'
3333
import { NamespaceRepository } from '../repositories/NamespaceRepository.js';
3434
import { InspectorSchemaChange } from '../services/SchemaUsageTrafficInspector.js';
3535
import { SchemaCheckChangeAction } from '../../db/models.js';
36+
import { traced } from '../tracing.js';
3637
import {
3738
composeGraphsInWorker,
3839
DeserializedComposedGraph,
@@ -136,6 +137,7 @@ export type CheckSubgraph = {
136137
// will be used only for new subgraphs
137138
labels?: Label[];
138139
};
140+
@traced
139141
export class Composer {
140142
constructor(
141143
private logger: FastifyBaseLogger,

controlplane/src/core/repositories/ApiKeyRepository.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
44
import * as schema from '../../db/schema.js';
55
import { apiKeyPermissions, apiKeyResources, apiKeys, users } from '../../db/schema.js';
66
import { APIKeyDTO } from '../../types/index.js';
7+
import { traced } from '../tracing.js';
78

89
/**
910
* Repository for organization related operations.
1011
*/
12+
@traced
1113
export class ApiKeyRepository {
1214
constructor(private db: PostgresJsDatabase<typeof schema>) {}
1315

0 commit comments

Comments
 (0)