Skip to content

Commit a2fe62c

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
feat(webapp): add lightweight external OIDC telemetry
Mono-RevId: a8ecd733ed7496919dee9d72f010d949bafe9514
1 parent 276f1f7 commit a2fe62c

6 files changed

Lines changed: 205 additions & 73 deletions
Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,36 @@
11
import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
22
import { env } from "~/env.server";
3+
import { withOidcMetadataTelemetry } from "~/services/oidc/externalOidcTelemetry.server";
34
import { logger } from "~/services/logger.server";
45
import { externalOidcSigningService } from "~/services/oidc/instance.server";
56
import { OIDC_JWKS_HTTP_MAX_AGE_SECONDS } from "~/services/oidc/constants";
67

7-
export async function loader({ request, params }: LoaderFunctionArgs) {
8-
if (request.method !== "GET" && request.method !== "HEAD") {
9-
return new Response(null, { status: 405, headers: { Allow: "GET, HEAD" } });
10-
}
11-
if (!env.OIDC_ISSUER_URL || !params.organizationSlug) {
12-
return new Response(null, { status: 404, headers: { "Cache-Control": "no-store" } });
13-
}
8+
export function loader({ request, params }: LoaderFunctionArgs) {
9+
return withOidcMetadataTelemetry("jwks", async () => {
10+
if (request.method !== "GET" && request.method !== "HEAD") {
11+
return new Response(null, { status: 405, headers: { Allow: "GET, HEAD" } });
12+
}
13+
if (!env.OIDC_ISSUER_URL || !params.organizationSlug) {
14+
return new Response(null, { status: 404, headers: { "Cache-Control": "no-store" } });
15+
}
1416

15-
try {
16-
const jwks = await externalOidcSigningService.getPublicJwks();
17-
return json(jwks, {
18-
headers: {
19-
"Cache-Control": `public, max-age=${OIDC_JWKS_HTTP_MAX_AGE_SECONDS}`,
20-
"Access-Control-Allow-Origin": "*",
21-
},
22-
});
23-
} catch {
24-
logger.error("Unable to load OIDC public keys");
25-
return json(
26-
{ error: "OIDC public keys unavailable" },
27-
{
28-
status: 503,
29-
headers: { "Cache-Control": "no-store" },
30-
}
31-
);
32-
}
17+
try {
18+
const jwks = await externalOidcSigningService.getPublicJwks();
19+
return json(jwks, {
20+
headers: {
21+
"Cache-Control": `public, max-age=${OIDC_JWKS_HTTP_MAX_AGE_SECONDS}`,
22+
"Access-Control-Allow-Origin": "*",
23+
},
24+
});
25+
} catch {
26+
logger.error("Unable to load OIDC public keys");
27+
return json(
28+
{ error: "OIDC public keys unavailable" },
29+
{
30+
status: 503,
31+
headers: { "Cache-Control": "no-store" },
32+
}
33+
);
34+
}
35+
});
3336
}
Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
22
import { env } from "~/env.server";
3+
import { withOidcMetadataTelemetry } from "~/services/oidc/externalOidcTelemetry.server";
34
import { OIDC_JWKS_HTTP_MAX_AGE_SECONDS } from "~/services/oidc/constants";
45
import { oidcDiscovery } from "~/utils/oidcIssuer";
56

67
export function loader({ request, params }: LoaderFunctionArgs) {
7-
if (request.method !== "GET" && request.method !== "HEAD") {
8-
return new Response(null, { status: 405, headers: { Allow: "GET, HEAD" } });
9-
}
10-
if (!env.OIDC_ISSUER_URL || !params.organizationSlug) {
11-
return new Response(null, { status: 404, headers: { "Cache-Control": "no-store" } });
12-
}
8+
return withOidcMetadataTelemetry("discovery", () => {
9+
if (request.method !== "GET" && request.method !== "HEAD") {
10+
return new Response(null, { status: 405, headers: { Allow: "GET, HEAD" } });
11+
}
12+
if (!env.OIDC_ISSUER_URL || !params.organizationSlug) {
13+
return new Response(null, { status: 404, headers: { "Cache-Control": "no-store" } });
14+
}
1315

14-
return json(oidcDiscovery(env.OIDC_ISSUER_URL, params.organizationSlug), {
15-
headers: {
16-
"Cache-Control": `public, max-age=${OIDC_JWKS_HTTP_MAX_AGE_SECONDS}`,
17-
"Access-Control-Allow-Origin": "*",
18-
},
16+
return json(oidcDiscovery(env.OIDC_ISSUER_URL, params.organizationSlug), {
17+
headers: {
18+
"Cache-Control": `public, max-age=${OIDC_JWKS_HTTP_MAX_AGE_SECONDS}`,
19+
"Access-Control-Allow-Origin": "*",
20+
},
21+
});
1922
});
2023
}

‎apps/webapp/app/services/oidc/externalOidc.server.ts‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { getMeter } from "@internal/tracing";
21
import { RunId } from "@trigger.dev/core/v3/isomorphic";
32
import type {
43
ExternalOidcController,
@@ -126,9 +125,3 @@ export function getExternalOidcRateLimits(): ExternalOidcRateLimits {
126125
)
127126
);
128127
}
129-
130-
export const externalOidcOutcomes = singleton("externalOidcOutcomes", () =>
131-
getMeter("external-oidc").createCounter("external_oidc.token.requests", {
132-
description: "External OIDC token requests by outcome",
133-
})
134-
);
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { getMeter } from "@internal/tracing";
2+
import { singleton } from "~/utils/singleton";
3+
4+
const DURATION_BUCKETS_MS = [1, 5, 10, 25, 50, 100, 250, 500, 1_000, 2_500, 5_000];
5+
6+
export function externalOidcTelemetry() {
7+
return singleton("externalOidcTelemetry", () => {
8+
const meter = getMeter("external-oidc");
9+
return {
10+
tokenRequests: meter.createCounter("external_oidc.token.requests", {
11+
description: "External OIDC token requests by outcome",
12+
}),
13+
tokenDuration: meter.createHistogram("external_oidc.token.duration", {
14+
description: "External OIDC token request duration by outcome",
15+
unit: "ms",
16+
advice: { explicitBucketBoundaries: DURATION_BUCKETS_MS },
17+
}),
18+
metadataDuration: meter.createHistogram("external_oidc.metadata.duration", {
19+
description: "External OIDC metadata request duration and count",
20+
unit: "ms",
21+
advice: { explicitBucketBoundaries: DURATION_BUCKETS_MS },
22+
}),
23+
};
24+
});
25+
}
26+
27+
export async function withOidcMetadataTelemetry(
28+
endpoint: "jwks" | "discovery",
29+
fn: () => Response | Promise<Response>
30+
): Promise<Response> {
31+
const startedAt = performance.now();
32+
let status = 500;
33+
try {
34+
const response = await fn();
35+
status = response.status;
36+
return response;
37+
} catch (error) {
38+
if (error instanceof Response) status = error.status;
39+
throw error;
40+
} finally {
41+
externalOidcTelemetry().metadataDuration.record(performance.now() - startedAt, {
42+
endpoint,
43+
status_class: `${Math.floor(status / 100)}xx`,
44+
});
45+
}
46+
}

‎apps/webapp/app/services/oidc/externalOidcTokenAction.server.ts‎

Lines changed: 56 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1+
import { trace } from "@internal/tracing";
12
import { json, type ActionFunctionArgs } from "@remix-run/node";
23
import { decodeJwt } from "jose";
34
import { z } from "zod";
45
import { env } from "~/env.server";
56
import { logger } from "~/services/logger.server";
6-
import {
7-
externalOidcOutcomes,
8-
getExternalOidcController,
9-
getExternalOidcRateLimits,
10-
} from "./externalOidc.server";
7+
import { getExternalOidcController, getExternalOidcRateLimits } from "./externalOidc.server";
118
import { internalRunSigningService } from "./instance.server";
12-
import { isRunTokenFormat, verifyRunToken } from "./runTokenVerify";
9+
import { externalOidcTelemetry } from "./externalOidcTelemetry.server";
10+
import { isRunTokenFormat, verifyRunToken, type RunTokenClaims } from "./runTokenVerify";
1311

1412
const MAX_AUDIENCE_LENGTH = 2_048;
1513

@@ -26,35 +24,40 @@ const TokenRequest = z
2624

2725
const NO_STORE = { "Cache-Control": "no-store" };
2826

29-
function failure(
30-
code: string,
31-
error: string,
32-
status: number,
33-
headers: Record<string, string> = {}
34-
) {
35-
externalOidcOutcomes.add(1, { outcome: code });
36-
return json({ error, code }, { status, headers: { ...NO_STORE, ...headers } });
37-
}
38-
3927
export async function externalOidcTokenAction({ request }: ActionFunctionArgs) {
40-
if (request.method !== "POST") {
41-
return failure("METHOD_NOT_ALLOWED", "Method not allowed", 405, { Allow: "POST" });
42-
}
28+
const startedAt = performance.now();
29+
const span = trace.getActiveSpan();
30+
let outcome = "OIDC_SERVICE_UNAVAILABLE";
31+
let claims: RunTokenClaims | undefined;
4332

44-
const controller = await getExternalOidcController();
45-
if (!controller) {
46-
return failure(
47-
"OIDC_UNAVAILABLE",
48-
"External OIDC is only available on Trigger.dev Cloud",
49-
503,
50-
{ "x-should-retry": "false" }
51-
);
33+
function failure(
34+
code: string,
35+
error: string,
36+
status: number,
37+
headers: Record<string, string> = {}
38+
) {
39+
outcome = code;
40+
return json({ error, code }, { status, headers: { ...NO_STORE, ...headers } });
5241
}
5342

5443
try {
44+
if (request.method !== "POST") {
45+
return failure("METHOD_NOT_ALLOWED", "Method not allowed", 405, { Allow: "POST" });
46+
}
47+
48+
const controller = await getExternalOidcController();
49+
if (!controller) {
50+
return failure(
51+
"OIDC_UNAVAILABLE",
52+
"External OIDC is only available on Trigger.dev Cloud",
53+
503,
54+
{ "x-should-retry": "false" }
55+
);
56+
}
57+
5558
// Only run tokens: root keys, PATs, sessions and public JWTs cannot mint.
5659
const bearer = /^Bearer ([^\s]+)$/i.exec(request.headers.get("Authorization") ?? "")?.[1];
57-
const claims =
60+
claims =
5861
bearer && env.RUN_TOKEN_ISSUER_URL && isRunTokenFormat(bearer)
5962
? await verifyRunToken({
6063
token: bearer,
@@ -100,17 +103,40 @@ export async function externalOidcTokenAction({ request }: ActionFunctionArgs) {
100103

101104
const expiry = decodeJwt(result.token).exp;
102105
if (typeof expiry !== "number") throw new Error("OIDC plugin returned a token without expiry");
103-
externalOidcOutcomes.add(1, { outcome: "minted" });
106+
outcome = "minted";
104107
return json(
105108
{ token: result.token, expiresAt: new Date(expiry * 1_000).toISOString() },
106109
{ headers: NO_STORE }
107110
);
108111
} catch (error) {
109-
logger.error("External OIDC mint failed", { error });
112+
logger.error("External OIDC mint failed", {
113+
error,
114+
organizationId: claims?.org_id,
115+
projectId: claims?.project_id,
116+
environmentId: claims?.environment_id,
117+
runId: claims?.run_id,
118+
});
110119
return failure(
111120
"OIDC_SERVICE_UNAVAILABLE",
112121
"OIDC token service is temporarily unavailable",
113122
503
114123
);
124+
} finally {
125+
const telemetry = externalOidcTelemetry();
126+
telemetry.tokenRequests.add(1, { outcome });
127+
telemetry.tokenDuration.record(performance.now() - startedAt, { outcome });
128+
// Reuse the request's sampling decision; tenant IDs never become metric labels.
129+
if (span?.isRecording()) {
130+
span.setAttributes({
131+
"oidc.outcome": outcome,
132+
...(claims && {
133+
"oidc.organization_id": claims.org_id,
134+
"oidc.project_id": claims.project_id,
135+
"oidc.environment_id": claims.environment_id,
136+
"oidc.run_id": claims.run_id,
137+
"oidc.attempt": claims.attempt,
138+
}),
139+
});
140+
}
115141
}
116142
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { metrics } from "@opentelemetry/api";
2+
import {
3+
InMemoryMetricExporter,
4+
MeterProvider,
5+
PeriodicExportingMetricReader,
6+
} from "@opentelemetry/sdk-metrics";
7+
import { expect, it } from "vitest";
8+
import { withOidcMetadataTelemetry } from "../app/services/oidc/externalOidcTelemetry.server";
9+
10+
// Catches: metadata instrumentation swallowing responses/errors or omitting failed requests.
11+
it("preserves metadata responses and errors while counting their HTTP outcomes", async () => {
12+
const exporter = new InMemoryMetricExporter();
13+
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 });
14+
const provider = new MeterProvider({ readers: [reader] });
15+
metrics.setGlobalMeterProvider(provider);
16+
17+
try {
18+
const response = new Response("keys", { headers: { "Cache-Control": "public, max-age=300" } });
19+
expect(await withOidcMetadataTelemetry("jwks", () => response)).toBe(response);
20+
21+
const notFound = new Response(null, { status: 404 });
22+
await expect(
23+
withOidcMetadataTelemetry("discovery", () => {
24+
throw notFound;
25+
})
26+
).rejects.toBe(notFound);
27+
28+
const error = new Error("key store unavailable");
29+
await expect(
30+
withOidcMetadataTelemetry("jwks", () => {
31+
throw error;
32+
})
33+
).rejects.toBe(error);
34+
35+
await provider.forceFlush();
36+
const points = exporter
37+
.getMetrics()
38+
.flatMap((resource) =>
39+
resource.scopeMetrics.flatMap((scope) =>
40+
scope.metrics
41+
.filter((metric) => metric.descriptor.name === "external_oidc.metadata.duration")
42+
.flatMap((metric) => metric.dataPoints)
43+
)
44+
);
45+
expect(
46+
points.map((point) => ({
47+
attributes: point.attributes,
48+
count: typeof point.value === "object" ? point.value.count : undefined,
49+
}))
50+
).toEqual(
51+
expect.arrayContaining([
52+
{ attributes: { endpoint: "jwks", status_class: "2xx" }, count: 1 },
53+
{ attributes: { endpoint: "discovery", status_class: "4xx" }, count: 1 },
54+
{ attributes: { endpoint: "jwks", status_class: "5xx" }, count: 1 },
55+
])
56+
);
57+
} finally {
58+
await provider.shutdown();
59+
metrics.disable();
60+
}
61+
});

0 commit comments

Comments
 (0)