@effect-gql/opentelemetry 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts DELETED
@@ -1,139 +0,0 @@
1
- /**
2
- * @effect-gql/opentelemetry
3
- *
4
- * OpenTelemetry tracing integration for Effect GraphQL.
5
- *
6
- * Provides distributed tracing using Effect's native OpenTelemetry support.
7
- * Works with any OpenTelemetry-compatible backend (Jaeger, Tempo, Honeycomb, etc.).
8
- *
9
- * @example
10
- * ```typescript
11
- * import { GraphQLSchemaBuilder } from "@effect-gql/core"
12
- * import { serve } from "@effect-gql/node"
13
- * import { withTracing } from "@effect-gql/opentelemetry"
14
- * import { NodeSdk } from "@effect/opentelemetry"
15
- * import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"
16
- * import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"
17
- *
18
- * // Add tracing to schema
19
- * const builder = GraphQLSchemaBuilder.empty
20
- * .query("users", { ... })
21
- * .pipe(withTracing({
22
- * extension: { exposeTraceIdInResponse: true },
23
- * resolver: { excludePatterns: [/^Query\.__/] }
24
- * }))
25
- *
26
- * // Configure OpenTelemetry
27
- * const TracingLayer = NodeSdk.layer(() => ({
28
- * resource: { serviceName: "my-graphql-api" },
29
- * spanProcessor: new BatchSpanProcessor(
30
- * new OTLPTraceExporter({ url: "http://localhost:4318/v1/traces" })
31
- * )
32
- * }))
33
- *
34
- * // Serve with tracing
35
- * serve(builder, TracingLayer.pipe(Layer.merge(serviceLayer)))
36
- * ```
37
- *
38
- * @packageDocumentation
39
- */
40
-
41
- import type { GraphQLSchemaBuilder } from "@effect-gql/core"
42
- import { tracingExtension, type TracingExtensionConfig } from "./tracing-extension"
43
- import { resolverTracingMiddleware, type ResolverTracingConfig } from "./tracing-middleware"
44
-
45
- /**
46
- * Complete configuration for GraphQL tracing
47
- */
48
- export interface GraphQLTracingConfig {
49
- /**
50
- * Configuration for phase-level tracing (parse, validate, execute).
51
- * Uses the Extensions system.
52
- */
53
- readonly extension?: TracingExtensionConfig
54
-
55
- /**
56
- * Configuration for resolver-level tracing.
57
- * Uses the Middleware system.
58
- */
59
- readonly resolver?: ResolverTracingConfig
60
- }
61
-
62
- /**
63
- * Add OpenTelemetry tracing to a GraphQL schema builder.
64
- *
65
- * This is a convenience function that registers both the tracing extension
66
- * (for phase-level spans) and resolver middleware (for field-level spans).
67
- *
68
- * **Span Hierarchy:**
69
- * ```
70
- * graphql.request (if using traced router)
71
- * ├── graphql.parse
72
- * ├── graphql.validate
73
- * └── graphql.execute
74
- * ├── graphql.resolve Query.users
75
- * ├── graphql.resolve User.posts
76
- * └── graphql.resolve Post.author
77
- * ```
78
- *
79
- * **Requirements:**
80
- * - An OpenTelemetry tracer must be provided via Effect's tracing layer
81
- * - Use `@effect/opentelemetry` NodeSdk.layer or OtlpTracer.layer
82
- *
83
- * @example
84
- * ```typescript
85
- * import { GraphQLSchemaBuilder } from "@effect-gql/core"
86
- * import { withTracing } from "@effect-gql/opentelemetry"
87
- *
88
- * const builder = GraphQLSchemaBuilder.empty
89
- * .query("hello", {
90
- * type: S.String,
91
- * resolve: () => Effect.succeed("world")
92
- * })
93
- * .pipe(withTracing({
94
- * extension: {
95
- * exposeTraceIdInResponse: true, // Add traceId to response extensions
96
- * includeQuery: false, // Don't include query in spans (security)
97
- * },
98
- * resolver: {
99
- * minDepth: 0, // Trace all resolvers
100
- * excludePatterns: [/^Query\.__/], // Skip introspection
101
- * includeArgs: false, // Don't include args (security)
102
- * }
103
- * }))
104
- * ```
105
- *
106
- * @param config - Optional tracing configuration
107
- * @returns A function that adds tracing to a GraphQLSchemaBuilder
108
- */
109
- export const withTracing =
110
- <R>(config?: GraphQLTracingConfig) =>
111
- (builder: GraphQLSchemaBuilder<R>): GraphQLSchemaBuilder<R> => {
112
- // Add tracing extension for phase-level spans
113
- let result = builder.extension(tracingExtension(config?.extension))
114
-
115
- // Add resolver tracing middleware for field-level spans
116
- result = result.middleware(resolverTracingMiddleware(config?.resolver))
117
-
118
- return result as GraphQLSchemaBuilder<R>
119
- }
120
-
121
- // Re-export components for individual use
122
- export { tracingExtension, type TracingExtensionConfig } from "./tracing-extension"
123
- export { resolverTracingMiddleware, type ResolverTracingConfig } from "./tracing-middleware"
124
- export {
125
- extractTraceContext,
126
- parseTraceParent,
127
- formatTraceParent,
128
- isSampled,
129
- TraceContextTag,
130
- TRACEPARENT_HEADER,
131
- TRACESTATE_HEADER,
132
- type TraceContext,
133
- } from "./context-propagation"
134
- export { pathToString, getFieldDepth, isIntrospectionField } from "./utils"
135
- export {
136
- makeTracedGraphQLRouter,
137
- withTracedRouter,
138
- type TracedRouterOptions,
139
- } from "./traced-router"
@@ -1,240 +0,0 @@
1
- import { HttpRouter, HttpServerRequest, HttpServerResponse } from "@effect/platform"
2
- import { Effect, Layer, Tracer } from "effect"
3
- import type { GraphQLSchema } from "graphql"
4
- import { makeGraphQLRouter, type MakeGraphQLRouterOptions } from "@effect-gql/core/server"
5
- import { extractTraceContext, type TraceContext } from "./context-propagation"
6
-
7
- /**
8
- * Options for the traced GraphQL router
9
- */
10
- export interface TracedRouterOptions extends MakeGraphQLRouterOptions {
11
- /**
12
- * Name for the root HTTP span.
13
- * Default: "graphql.http"
14
- */
15
- readonly rootSpanName?: string
16
-
17
- /**
18
- * Additional attributes to add to the root span.
19
- */
20
- readonly rootSpanAttributes?: Record<string, string | number | boolean>
21
-
22
- /**
23
- * Whether to propagate trace context from incoming HTTP headers.
24
- * Uses W3C Trace Context (traceparent header).
25
- * Default: true
26
- */
27
- readonly propagateContext?: boolean
28
- }
29
-
30
- /**
31
- * Create an Effect span options object from trace context
32
- */
33
- const createSpanOptions = (
34
- traceContext: TraceContext | null,
35
- request: HttpServerRequest.HttpServerRequest,
36
- config: TracedRouterOptions
37
- ): {
38
- attributes?: Record<string, unknown>
39
- parent?: Tracer.ExternalSpan
40
- } => {
41
- const attributes: Record<string, unknown> = {
42
- "http.method": request.method,
43
- "http.url": request.url,
44
- "http.target": request.url,
45
- ...config.rootSpanAttributes,
46
- }
47
-
48
- if (traceContext && config.propagateContext !== false) {
49
- return {
50
- attributes,
51
- parent: Tracer.externalSpan({
52
- traceId: traceContext.traceId,
53
- spanId: traceContext.parentSpanId,
54
- sampled: (traceContext.traceFlags & 0x01) === 0x01,
55
- }),
56
- }
57
- }
58
-
59
- return { attributes }
60
- }
61
-
62
- /**
63
- * Creates a GraphQL router with OpenTelemetry tracing at the HTTP level.
64
- *
65
- * This wraps the standard makeGraphQLRouter to:
66
- * 1. Extract trace context from incoming HTTP headers (W3C Trace Context)
67
- * 2. Create a root span for the entire HTTP request
68
- * 3. Propagate trace context to child spans created by extensions/middleware
69
- *
70
- * **Span Hierarchy:**
71
- * ```
72
- * graphql.http (created by this router)
73
- * ├── graphql.parse (from tracing extension)
74
- * ├── graphql.validate (from tracing extension)
75
- * └── graphql.resolve Query.* (from tracing middleware)
76
- * ```
77
- *
78
- * @example
79
- * ```typescript
80
- * import { makeTracedGraphQLRouter } from "@effect-gql/opentelemetry"
81
- * import { NodeSdk } from "@effect/opentelemetry"
82
- *
83
- * const router = makeTracedGraphQLRouter(schema, serviceLayer, {
84
- * path: "/graphql",
85
- * graphiql: { path: "/graphiql" },
86
- * rootSpanName: "graphql.http",
87
- * rootSpanAttributes: {
88
- * "service.name": "my-api"
89
- * }
90
- * })
91
- *
92
- * // Provide OpenTelemetry layer when serving
93
- * const TracingLayer = NodeSdk.layer(() => ({
94
- * resource: { serviceName: "my-graphql-api" },
95
- * spanProcessor: new BatchSpanProcessor(new OTLPTraceExporter())
96
- * }))
97
- * ```
98
- *
99
- * @param schema - The GraphQL schema
100
- * @param layer - Effect layer providing services required by resolvers
101
- * @param options - Router and tracing configuration
102
- * @returns An HttpRouter with tracing enabled
103
- */
104
- export const makeTracedGraphQLRouter = <R>(
105
- schema: GraphQLSchema,
106
- layer: Layer.Layer<R>,
107
- options: TracedRouterOptions = {}
108
- ): HttpRouter.HttpRouter<never, never> => {
109
- const rootSpanName = options.rootSpanName ?? "graphql.http"
110
-
111
- // Create the base router (handles GraphQL logic)
112
- const baseRouter = makeGraphQLRouter(schema, layer, options)
113
-
114
- // Convert base router to an HttpApp for Effect-based handling
115
- const baseApp = HttpRouter.toHttpApp(baseRouter)
116
-
117
- // Wrap with tracing
118
- return HttpRouter.empty.pipe(
119
- HttpRouter.all(
120
- "*",
121
- Effect.gen(function* () {
122
- const request = yield* HttpServerRequest.HttpServerRequest
123
-
124
- // Extract trace context from headers (if enabled)
125
- const traceContext =
126
- options.propagateContext !== false
127
- ? yield* extractTraceContext.pipe(Effect.catchAll(() => Effect.succeed(null)))
128
- : null
129
-
130
- // Create span options with parent context if available
131
- const spanOptions = createSpanOptions(traceContext, request, options)
132
-
133
- // Execute the request inside a root span
134
- return yield* Effect.withSpan(
135
- rootSpanName,
136
- spanOptions
137
- )(
138
- Effect.gen(function* () {
139
- // Delegate to the base app (which handles the request from context)
140
- const app = yield* baseApp
141
- const response = yield* app.pipe(
142
- Effect.catchTag("RouteNotFound", () =>
143
- HttpServerResponse.text(JSON.stringify({ errors: [{ message: "Not Found" }] }), {
144
- status: 404,
145
- headers: { "content-type": "application/json" },
146
- })
147
- )
148
- )
149
-
150
- // Annotate span with response info
151
- yield* Effect.annotateCurrentSpan("http.status_code", response.status)
152
-
153
- return response
154
- })
155
- )
156
- })
157
- )
158
- )
159
- }
160
-
161
- /**
162
- * Wrap an existing HttpRouter with OpenTelemetry tracing.
163
- *
164
- * This is useful when you already have a router and want to add
165
- * request-level tracing without recreating it.
166
- *
167
- * @example
168
- * ```typescript
169
- * import { toRouter } from "@effect-gql/core/server"
170
- * import { withTracedRouter } from "@effect-gql/opentelemetry"
171
- *
172
- * const baseRouter = toRouter(builder, serviceLayer)
173
- * const tracedRouter = withTracedRouter(baseRouter, {
174
- * rootSpanName: "graphql.http"
175
- * })
176
- * ```
177
- */
178
- export const withTracedRouter = (
179
- router: HttpRouter.HttpRouter<any, any>,
180
- options: {
181
- rootSpanName?: string
182
- rootSpanAttributes?: Record<string, string | number | boolean>
183
- propagateContext?: boolean
184
- } = {}
185
- ): HttpRouter.HttpRouter<any, any> => {
186
- const rootSpanName = options.rootSpanName ?? "graphql.http"
187
-
188
- // Convert router to an HttpApp for Effect-based handling
189
- const baseApp = HttpRouter.toHttpApp(router)
190
-
191
- return HttpRouter.empty.pipe(
192
- HttpRouter.all(
193
- "*",
194
- Effect.gen(function* () {
195
- const request = yield* HttpServerRequest.HttpServerRequest
196
-
197
- // Extract trace context from headers
198
- const traceContext =
199
- options.propagateContext !== false
200
- ? yield* extractTraceContext.pipe(Effect.catchAll(() => Effect.succeed(null)))
201
- : null
202
-
203
- const spanOptions: {
204
- attributes: Record<string, unknown>
205
- parent?: Tracer.ExternalSpan
206
- } = {
207
- attributes: {
208
- "http.method": request.method,
209
- "http.url": request.url,
210
- ...options.rootSpanAttributes,
211
- },
212
- }
213
-
214
- if (traceContext && options.propagateContext !== false) {
215
- spanOptions.parent = Tracer.externalSpan({
216
- traceId: traceContext.traceId,
217
- spanId: traceContext.parentSpanId,
218
- sampled: (traceContext.traceFlags & 0x01) === 0x01,
219
- })
220
- }
221
-
222
- return yield* Effect.withSpan(
223
- rootSpanName,
224
- spanOptions
225
- )(
226
- Effect.gen(function* () {
227
- // Delegate to the base app (which handles the request from context)
228
- const app = yield* baseApp
229
- const response = yield* app
230
-
231
- // Annotate span with response info
232
- yield* Effect.annotateCurrentSpan("http.status_code", response.status)
233
-
234
- return response
235
- })
236
- )
237
- })
238
- )
239
- )
240
- }
@@ -1,175 +0,0 @@
1
- import { Effect, Option } from "effect"
2
- import type { DocumentNode, ExecutionResult, GraphQLError, OperationDefinitionNode } from "graphql"
3
- import type { GraphQLExtension, ExecutionArgs } from "@effect-gql/core"
4
- import { ExtensionsService } from "@effect-gql/core"
5
-
6
- /**
7
- * Configuration for the GraphQL tracing extension
8
- */
9
- export interface TracingExtensionConfig {
10
- /**
11
- * Include the query source in span attributes.
12
- * Default: false (for security - queries may contain sensitive data)
13
- */
14
- readonly includeQuery?: boolean
15
-
16
- /**
17
- * Include variables in span attributes.
18
- * Default: false (for security - variables may contain sensitive data)
19
- */
20
- readonly includeVariables?: boolean
21
-
22
- /**
23
- * Add trace ID and span ID to the GraphQL response extensions.
24
- * Useful for correlating client requests with backend traces.
25
- * Default: false
26
- */
27
- readonly exposeTraceIdInResponse?: boolean
28
-
29
- /**
30
- * Custom attributes to add to all spans.
31
- */
32
- readonly customAttributes?: Record<string, string | number | boolean>
33
- }
34
-
35
- /**
36
- * Extract the operation name from a parsed GraphQL document
37
- */
38
- const getOperationName = (document: DocumentNode): string | undefined => {
39
- for (const definition of document.definitions) {
40
- if (definition.kind === "OperationDefinition") {
41
- return definition.name?.value
42
- }
43
- }
44
- return undefined
45
- }
46
-
47
- /**
48
- * Extract the operation type (query, mutation, subscription) from a parsed document
49
- */
50
- const getOperationType = (document: DocumentNode): string => {
51
- for (const definition of document.definitions) {
52
- if (definition.kind === "OperationDefinition") {
53
- return (definition as OperationDefinitionNode).operation
54
- }
55
- }
56
- return "unknown"
57
- }
58
-
59
- /**
60
- * Creates a GraphQL extension that adds OpenTelemetry tracing to all execution phases.
61
- *
62
- * This extension:
63
- * - Creates spans for parse, validate phases
64
- * - Annotates the current span with operation metadata during execution
65
- * - Optionally exposes trace ID in response extensions
66
- *
67
- * Requires an OpenTelemetry tracer to be provided via Effect's tracing layer
68
- * (e.g., `@effect/opentelemetry` NodeSdk.layer or OtlpTracer.layer).
69
- *
70
- * @example
71
- * ```typescript
72
- * import { tracingExtension } from "@effect-gql/opentelemetry"
73
- *
74
- * const builder = GraphQLSchemaBuilder.empty.pipe(
75
- * extension(tracingExtension({
76
- * exposeTraceIdInResponse: true
77
- * })),
78
- * query("hello", { ... })
79
- * )
80
- * ```
81
- */
82
- export const tracingExtension = (
83
- config?: TracingExtensionConfig
84
- ): GraphQLExtension<ExtensionsService> => ({
85
- name: "opentelemetry-tracing",
86
- description: "Adds OpenTelemetry tracing to GraphQL execution phases",
87
-
88
- onParse: (source: string, document: DocumentNode) =>
89
- Effect.withSpan("graphql.parse")(
90
- Effect.gen(function* () {
91
- const operationName = getOperationName(document) ?? "anonymous"
92
- yield* Effect.annotateCurrentSpan("graphql.document.name", operationName)
93
- yield* Effect.annotateCurrentSpan(
94
- "graphql.document.operation_count",
95
- document.definitions.filter((d) => d.kind === "OperationDefinition").length
96
- )
97
-
98
- if (config?.includeQuery) {
99
- yield* Effect.annotateCurrentSpan("graphql.source", source)
100
- }
101
-
102
- // Add custom attributes if provided
103
- if (config?.customAttributes) {
104
- for (const [key, value] of Object.entries(config.customAttributes)) {
105
- yield* Effect.annotateCurrentSpan(key, value)
106
- }
107
- }
108
- })
109
- ),
110
-
111
- onValidate: (document: DocumentNode, errors: readonly GraphQLError[]) =>
112
- Effect.withSpan("graphql.validate")(
113
- Effect.gen(function* () {
114
- yield* Effect.annotateCurrentSpan("graphql.validation.error_count", errors.length)
115
-
116
- if (errors.length > 0) {
117
- yield* Effect.annotateCurrentSpan(
118
- "graphql.validation.errors",
119
- JSON.stringify(errors.map((e) => e.message))
120
- )
121
- yield* Effect.annotateCurrentSpan("error", true)
122
- }
123
- })
124
- ),
125
-
126
- onExecuteStart: (args: ExecutionArgs) =>
127
- Effect.gen(function* () {
128
- const operationName = args.operationName ?? getOperationName(args.document) ?? "anonymous"
129
- const operationType = getOperationType(args.document)
130
-
131
- yield* Effect.annotateCurrentSpan("graphql.operation.name", operationName)
132
- yield* Effect.annotateCurrentSpan("graphql.operation.type", operationType)
133
-
134
- if (config?.includeVariables && args.variableValues) {
135
- yield* Effect.annotateCurrentSpan("graphql.variables", JSON.stringify(args.variableValues))
136
- }
137
-
138
- // Expose trace ID in response extensions if configured
139
- if (config?.exposeTraceIdInResponse) {
140
- const currentSpanOption = yield* Effect.option(Effect.currentSpan)
141
- if (Option.isSome(currentSpanOption)) {
142
- const span = currentSpanOption.value
143
- const ext = yield* ExtensionsService
144
- yield* ext.set("tracing", {
145
- traceId: span.traceId,
146
- spanId: span.spanId,
147
- })
148
- }
149
- }
150
- }),
151
-
152
- onExecuteEnd: (result: ExecutionResult) =>
153
- Effect.gen(function* () {
154
- const hasErrors = result.errors !== undefined && result.errors.length > 0
155
-
156
- yield* Effect.annotateCurrentSpan("graphql.response.has_errors", hasErrors)
157
- yield* Effect.annotateCurrentSpan(
158
- "graphql.response.has_data",
159
- result.data !== null && result.data !== undefined
160
- )
161
-
162
- if (hasErrors) {
163
- yield* Effect.annotateCurrentSpan("error", true)
164
- yield* Effect.annotateCurrentSpan(
165
- "graphql.errors",
166
- JSON.stringify(
167
- result.errors!.map((e) => ({
168
- message: e.message,
169
- path: e.path,
170
- }))
171
- )
172
- )
173
- }
174
- }),
175
- })