Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e8598da
fix(observability): align OTLP exporters with the OpenTelemetry spec
Makisuo Oct 6, 2026
2665158
Focus OTLP convention tests by component
effect-bot Oct 6, 2026
c69a4fa
Keep OTLP conventions changes to traces, logs, exporter and resource
effect-bot Oct 6, 2026
69f158b
test: cover OTLP attribute collisions and executable fallback
effect-bot Oct 6, 2026
652a320
Fix OTLP log attribute collisions and service fallback
effect-bot Oct 6, 2026
5be5b74
docs: polish OTLP convention alignment notes
effect-bot Oct 6, 2026
52ee0b3
test: specify shared Effect versions and OpenTelemetry alignment
effect-bot Oct 6, 2026
648a6ed
Add effect/Version and align OpenTelemetry scopes, versions and logs
effect-bot Oct 6, 2026
05d6d5e
test: cover OpenTelemetry resource SDK default precedence
effect-bot Oct 6, 2026
d988868
fix: align OpenTelemetry resource SDK default precedence
effect-bot Oct 6, 2026
e6a74e9
docs: polish OTLP alignment release notes and comments
effect-bot Oct 6, 2026
1dd4c0b
Simplify OTLP alignment code and tests
effect-bot Oct 6, 2026
72aa98a
test: cover OtlpResource.make service option collisions
effect-bot Oct 6, 2026
8fa741a
docs: simplify OTLP changeset and comments
tim-smart Oct 6, 2026
cabce2c
test: require a service name for OTLP resources from config
effect-bot Oct 6, 2026
d5e0953
test: trim OTLP convention coverage
tim-smart Oct 6, 2026
d6e6a2c
Restore required OTLP service name configuration fallback
effect-bot Oct 6, 2026
abe9dc7
Clarify OTLP service name configuration in release notes
effect-bot Oct 6, 2026
e39c32a
Apply suggestion from @tim-smart
tim-smart Oct 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/otlp-exporters-semconv.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"effect": patch
"@effect/opentelemetry": patch
---

Align OTLP and `@effect/opentelemetry` logs, traces, and resources with OpenTelemetry conventions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"ai-docgen:watch": "pnpm ai-docgen --watch",
"api-diff": "pnpm --dir packages/tools/api-diff exec node src/bin.ts",
"test-types": "tstyche --target '>=5.9'",
"changeset-version": "changeset version",
"changeset-version": "changeset version && node scripts/version.mjs",
"changeset-publish": "node scripts/set-strip-internal.mjs && pnpm codemod && pnpm build && changeset publish"
},
"devDependencies": {
Expand Down
35 changes: 35 additions & 0 deletions packages/effect/src/Version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* The `effect` package version used in telemetry headers, resources, and scopes.
*
* @since 4.0.2
*/
import { version } from "./internal/version.ts"

let currentVersion: string = version

/**
* Returns the current version of the `effect` package.
*
* **Details**
*
* Defaults to the release version unless overridden by `setCurrentVersion`.
*
* @category getters
* @since 4.0.2
*/
export const getCurrentVersion = (): string => currentVersion

/**
* Overrides the version returned by `getCurrentVersion`.
*
* **Gotchas**
*
* Applies to this copy of the module. Existing telemetry layers retain the
* version they read when built.
*
* @category setters
* @since 4.0.2
*/
export const setCurrentVersion = (version: string): void => {
currentVersion = version
}
5 changes: 5 additions & 0 deletions packages/effect/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -721,3 +721,8 @@ export * as Unify from "./Unify.ts"
* @since 2.0.0
*/
export * as Utils from "./Utils.ts"

/**
* @since 4.0.2
*/
export * as Version from "./Version.ts"
2 changes: 2 additions & 0 deletions packages/effect/src/internal/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** @internal */
export const version = "4.0.1"
15 changes: 9 additions & 6 deletions packages/effect/src/observability/OtlpExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import * as Num from "../Number.ts"
import * as Option from "../Option.ts"
import * as Schedule from "../Schedule.ts"
import * as Scope from "../Scope.ts"
import * as Version from "../Version.ts"

const retryAfterDelay = (value: string | undefined): Effect.Effect<Duration.Duration> => {
const seconds = Option.fromUndefinedOr(value).pipe(Option.flatMap(Num.parse))
Expand Down Expand Up @@ -194,12 +195,14 @@ export const make: (
HttpClient.retryTransient({ schedule: policy, times: 3 })
)

let headers = Headers.fromRecordUnsafe({
"user-agent": `effect-opentelemetry-${options.label}/0.0.0`
})
if (options.headers) {
headers = Headers.merge(Headers.fromInput(options.headers), headers)
}
const exporterUserAgent = `OTel-OTLP-Exporter-JavaScript-Effect-${options.label}/${Version.getCurrentVersion()}`
const userHeaders = Headers.fromInput(options.headers)
const userAgent = userHeaders["user-agent"]
const headers = Headers.set(
userHeaders,
"user-agent",
userAgent ? `${userAgent} ${exporterUserAgent}` : exporterUserAgent
)

const request = HttpClientRequest.post(options.url, { headers })
let buffer: Array<any> = []
Expand Down
41 changes: 20 additions & 21 deletions packages/effect/src/observability/OtlpLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type * as LogLevel from "../LogLevel.ts"
import * as Option from "../Option.ts"
import { CurrentLogAnnotations, CurrentLogSpans } from "../References.ts"
import type * as Scope from "../Scope.ts"
import * as Version from "../Version.ts"
import * as OtlpEnv from "./internal/otlpEnv.ts"
import * as Exporter from "./OtlpExporter.ts"
import type { AnyValue, Fixed64, KeyValue, Resource } from "./OtlpResource.ts"
Expand Down Expand Up @@ -64,7 +65,8 @@ export const make: (
const serialization = yield* OtlpSerialization
const otelResource = yield* OtlpResource.fromConfig(options.resource)
const scope: IInstrumentationScope = {
name: OtlpResource.serviceNameUnsafe(otelResource)
name: "effect",
version: Version.getCurrentVersion()
}

const exporter = yield* Exporter.make({
Expand Down Expand Up @@ -192,42 +194,39 @@ export interface LogsData {

// internal

const nanosPerMilli = BigInt(1_000_000)

const makeLogRecord = (options: Logger.Options<unknown>, opts: {
readonly excludeLogSpans: boolean
readonly clock: Clock
}): ILogRecord => {
const now = opts.clock.currentTimeNanosUnsafe()
const nanosString = now.toString()
const nowMillis = options.date.getTime()
const observedTime = opts.clock.currentTimeNanosUnsafe().toString()
const eventMillis = options.date.getTime()
const eventTime = (BigInt(eventMillis) * nanosPerMilli).toString()

const attributes = OtlpResource.entriesToAttributes(Object.entries(options.fiber.getRef(CurrentLogAnnotations)))
attributes.push({
key: "fiberId",
value: { intValue: options.fiber.id }
})
const attributes: Record<string, unknown> = { ...options.fiber.getRef(CurrentLogAnnotations) }
attributes["effect.fiberId"] = options.fiber.id
if (!opts.excludeLogSpans) {
// Outermost spans win duplicate labels.
for (const [label, startTime] of options.fiber.getRef(CurrentLogSpans)) {
attributes.push({
key: `logSpan.${label}`,
value: { stringValue: `${nowMillis - startTime}ms` }
})
attributes[`effect.log_span.${label}`] = eventMillis - startTime
}
}
if (options.cause.reasons.length > 0) {
attributes.push({
key: "log.error",
value: { stringValue: Cause.pretty(options.cause) }
})
const errors = Cause.prettyErrors(options.cause, { includeCauseInStack: true })
if (errors.length > 0) {
attributes["exception.type"] = errors[0].name
attributes["exception.message"] = errors[0].message
attributes["exception.stacktrace"] = errors.map((error) => error.stack).join("\n")
}

const message = Arr.ensure(options.message)

const logRecord: ILogRecord = {
severityNumber: logLevelToSeverityNumber(options.logLevel),
severityText: options.logLevel,
timeUnixNano: nanosString,
observedTimeUnixNano: nanosString,
attributes,
timeUnixNano: eventTime,
observedTimeUnixNano: observedTime,
attributes: OtlpResource.entriesToAttributes(Object.entries(attributes)),
body: OtlpResource.unknownToAttributeValue(message.length === 1 ? message[0] : message),
droppedAttributesCount: 0
}
Expand Down
45 changes: 18 additions & 27 deletions packages/effect/src/observability/OtlpResource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as Config from "../Config.ts"
import * as Effect from "../Effect.ts"
import { format } from "../Formatter.ts"
import * as Schema from "../Schema.ts"
import * as Version from "../Version.ts"

/**
* OTLP resource metadata attached to exported logs, metrics, and traces.
Expand All @@ -28,46 +29,36 @@ export interface Resource {
droppedAttributesCount: number
}

const isBrowser = "window" in globalThis || "importScripts" in globalThis

/**
* Creates an OTLP resource from service metadata and additional attributes.
*
* **Details**
*
* The resource always includes `service.name`, includes `service.version` when
* provided, and converts custom attributes into OTLP attribute values.
* Adds `telemetry.sdk.*` defaults and converts custom attributes to OTLP values.
* Custom attributes override SDK defaults; service options override matching
* attributes. Omitted service options add no service attributes.
*
* @stability unstable
* @category constructors
* @since 4.0.0
*/
export const make = (options: {
readonly serviceName: string
readonly serviceName?: string | undefined
readonly serviceVersion?: string | undefined
readonly attributes?: Record<string, unknown> | undefined
}): Resource => {
const resourceAttributes = options.attributes
? entriesToAttributes(Object.entries(options.attributes))
: []
resourceAttributes.push({
key: "service.name",
value: {
stringValue: options.serviceName
}
})
if (options.serviceVersion) {
resourceAttributes.push({
key: "service.version",
value: {
stringValue: options.serviceVersion
}
})
}

return {
attributes: resourceAttributes,
droppedAttributesCount: 0
}
}
}): Resource => ({
attributes: entriesToAttributes(Object.entries({
"telemetry.sdk.name": "effect",
"telemetry.sdk.language": isBrowser ? "webjs" : "nodejs",
"telemetry.sdk.version": Version.getCurrentVersion(),
...options.attributes,
...(options.serviceName ? { "service.name": options.serviceName } : undefined),
...(options.serviceVersion ? { "service.version": options.serviceVersion } : undefined)
})),
droppedAttributesCount: 0
})

/**
* Creates an OTLP resource from explicit options and OpenTelemetry
Expand Down
5 changes: 4 additions & 1 deletion packages/effect/src/observability/OtlpTracer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import * as Option from "../Option.ts"
import type * as Scope from "../Scope.ts"
import * as Tracer from "../Tracer.ts"
import type { ExtractTag } from "../Types.ts"
import * as Version from "../Version.ts"
import * as OtlpEnv from "./internal/otlpEnv.ts"
import * as Exporter from "./OtlpExporter.ts"
import type { KeyValue, Resource } from "./OtlpResource.ts"
Expand Down Expand Up @@ -67,7 +68,8 @@ export const make: (
const otelResource = yield* OtlpResource.fromConfig(options.resource)
const serialization = yield* OtlpSerialization
const scope: Scope = {
name: OtlpResource.serviceNameUnsafe(otelResource)
name: "effect",
version: Version.getCurrentVersion()
}

const exporter = yield* Exporter.make({
Expand Down Expand Up @@ -413,6 +415,7 @@ export interface ScopeSpan {

interface Scope {
readonly name: string
readonly version?: string
}

interface OtlpSpan {
Expand Down
17 changes: 17 additions & 0 deletions packages/effect/test/Version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { assert, describe, it } from "@effect/vitest"
import * as Version from "effect/Version"

describe("Version", () => {
it("exposes a real version and supports isolated version overrides", () => {
const original = Version.getCurrentVersion()
assert.match(original, /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/)
assert.notStrictEqual(original, "0.0.0")
try {
Version.setCurrentVersion("test-version")
assert.strictEqual(Version.getCurrentVersion(), "test-version")
} finally {
Version.setCurrentVersion(original)
}
assert.strictEqual(Version.getCurrentVersion(), original)
})
})
27 changes: 24 additions & 3 deletions packages/effect/test/observability/OtlpExporter.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { assert, describe, it } from "@effect/vitest"
import { Clock, ConfigProvider, Deferred, type Duration, Effect, Exit, Fiber, Layer, Metric, Ref, Scope } from "effect"
import { HttpBody, HttpClient, HttpClientResponse } from "effect/http"
import type { HttpClientError } from "effect/http"
import { type Headers, HttpBody, HttpClient, type HttpClientError, HttpClientResponse } from "effect/http"
import { OtlpExporter, OtlpLogger, OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/observability"
import { TestClock } from "effect/testing"
import * as Version from "effect/Version"

const makeHttpClient = Effect.fnUntraced(function*(retryAfter: string | undefined) {
const attempts = yield* Ref.make(0)
Expand Down Expand Up @@ -35,12 +35,13 @@ const makeExporter = (
readonly maxBatchSize?: number | "disabled"
readonly shutdownTimeout?: Duration.Input
readonly body?: (data: Array<any>) => HttpBody.HttpBody
readonly headers?: Headers.Input | undefined
}
) =>
OtlpExporter.make({
label: "OtlpExporterTest",
url: "http://localhost:4318/v1/logs",
headers: undefined,
headers: options?.headers,
exportInterval: options?.exportInterval ?? "1 hour",
maxBatchSize: options?.maxBatchSize ?? 1,
body: (data) => [options?.body?.(data) ?? HttpBody.empty, Effect.void],
Expand Down Expand Up @@ -107,6 +108,26 @@ const makeControlledHttpClient = Effect.fnUntraced(function*(requestCount: numbe
})

describe("OtlpExporter", () => {
it.effect("appends the versioned exporter User-Agent to a user-supplied value", () =>
Effect.gen(function*() {
const userAgents: Array<string> = []
const httpClient = HttpClient.makeWith(
Effect.fnUntraced(function*(requestEffect) {
const request = yield* requestEffect
userAgents.push(request.headers["user-agent"])
return HttpClientResponse.fromWeb(request, new Response())
}),
Effect.succeed as HttpClient.HttpClient.Preprocess<HttpClientError.HttpClientError, never>
)
yield* Effect.scoped(Effect.gen(function*() {
const exporter = yield* makeExporter(httpClient, { headers: { "UsEr-AgEnT": "my-app/1.0" }, maxBatchSize: 10 })
exporter.push({ value: 1 })
}))
assert.deepStrictEqual(userAgents, [
`my-app/1.0 OTel-OTLP-Exporter-JavaScript-Effect-OtlpExporterTest/${Version.getCurrentVersion()}`
])
}))

it.effect("allows an in-flight timer export to finish during shutdown", () =>
Effect.gen(function*() {
const scope = yield* Scope.make()
Expand Down
Loading
Loading