diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 9e06249..16847b7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -18,12 +18,16 @@ body: 1. Configure with... 2. Call... 3. Observe... + validations: + required: true - type: textarea id: expected attributes: label: Expected behavior placeholder: What you expected to happen. + validations: + required: true - type: dropdown id: module @@ -40,13 +44,17 @@ body: id: version attributes: label: Library version - placeholder: "0.1.0" + placeholder: "0.1.1 or commit SHA" + validations: + required: true - type: input id: java-version attributes: label: Java version placeholder: "17" + validations: + required: true - type: dropdown id: framework @@ -57,3 +65,21 @@ body: - LangChain4j - None (standalone) - Other + validations: + required: true + + - type: input + id: framework-versions + attributes: + label: Framework versions + description: Include Spring Boot and Spring AI or LangChain4j versions, or enter "n/a". + placeholder: "Spring Boot 3.5.16, Spring AI 1.1.8" + validations: + required: true + + - type: textarea + id: diagnostics + attributes: + label: Relevant configuration and logs + description: Remove API keys, credentials, model content, and other sensitive data. + render: shell diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4bf5b0a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 + +updates: + - package-ecosystem: maven + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 0 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 3 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index aa83a76..eb363d6 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,21 +1,14 @@ -## Summary +## What changed -## Changes - -- - ## Testing ## Checklist -- [ ] Compiles without errors (`mvn compile`) -- [ ] All unit tests pass (`mvn test`) -- [ ] New functionality has test coverage -- [ ] Core module stays Java 11 compatible -- [ ] No hardcoded API keys or secrets -- [ ] Tracing wrappers handle setup failures gracefully (no app-breaking exceptions) -- [ ] Streaming instrumentation uses raw `Span` without `makeCurrent()` (see DESIGN.md #13) +- [ ] `./mvnw -B -ntp clean verify` passes +- [ ] Tests cover the changed behavior +- [ ] Public API or user-facing behavior is documented +- [ ] No credentials or captured model data are included diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1131ad2..ece5272 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,16 +14,20 @@ concurrency: cancel-in-progress: true jobs: + quality: + name: quality and supply chain + uses: ./.github/workflows/quality.yml + verify: name: verify (Java 17, package + Javadoc) runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: persist-credentials: false - name: Set up Java 17 - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: temurin java-version: 17 @@ -32,18 +36,25 @@ jobs: - name: Clean verify run: ./mvnw -B -ntp clean verify + - name: Verify coverage artifacts + run: | + test -s langfuse-otel-core/target/jacoco.exec + test -s langfuse-otel-core/target/site/jacoco/jacoco.xml + test -s langfuse-otel-spring-boot-starter/target/jacoco.exec + test -s langfuse-otel-spring-boot-starter/target/site/jacoco/jacoco.xml + core: runs-on: ubuntu-latest strategy: matrix: java: [11, 17, 21] steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: persist-credentials: false - name: Set up Java ${{ matrix.java }} - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: temurin java-version: ${{ matrix.java }} @@ -58,12 +69,12 @@ jobs: fail-fast: false matrix: include: - # Baseline: minimum supported versions + # Baseline: minimum supported, security-patched versions - java: 17 - spring-ai-version: '1.0.0' + spring-ai-version: '1.0.9' langchain4j-version: '1.0.0' - java: 21 - spring-ai-version: '1.0.0' + spring-ai-version: '1.0.9' langchain4j-version: '1.0.0' # Latest stable releases on the Spring AI 1.x line and LangChain4j - java: 17 @@ -72,21 +83,14 @@ jobs: - java: 21 spring-ai-version: '1.1.8' langchain4j-version: '1.18.0' - # Current stable releases - - java: 17 - spring-ai-version: '2.0.0' - langchain4j-version: '1.18.0' - - java: 21 - spring-ai-version: '2.0.0' - langchain4j-version: '1.18.0' name: starter (java-${{ matrix.java }}, spring-ai-${{ matrix.spring-ai-version }}, lc4j-${{ matrix.langchain4j-version }}) steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: persist-credentials: false - name: Set up Java ${{ matrix.java }} - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: temurin java-version: ${{ matrix.java }} @@ -104,19 +108,19 @@ jobs: runs-on: ubuntu-latest needs: [verify] steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: persist-credentials: false - name: Set up Java 17 - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: temurin java-version: 17 cache: maven - name: Install snapshot artifacts - run: ./mvnw -B -ntp -DskipTests install + run: ./mvnw -B -ntp -DskipTests -Djacoco.skip=true install - name: Compile Spring AI consumer run: ./mvnw -B -ntp -f examples/spring-ai-example/pom.xml -DskipTests verify @@ -124,11 +128,14 @@ jobs: - name: Compile LangChain4j consumer run: ./mvnw -B -ntp -f examples/langchain4j-example/pom.xml -DskipTests verify + - name: Verify core and prompt client compatibility + run: ./mvnw -B -ntp -f consumer-tests/core-prompt-consumer/pom.xml verify + integration-status: name: live export smoke status (explicit opt-in) runs-on: ubuntu-latest if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: [verify, core, starter, consumer-smoke] + needs: [quality, verify, core, starter, consumer-smoke] outputs: enabled: ${{ steps.configuration.outputs.enabled }} env: @@ -186,12 +193,12 @@ jobs: LANGFUSE_HOST: ${{ secrets.LANGFUSE_HOST }} LANGFUSE_TEST_PROMPT_NAME: ${{ secrets.LANGFUSE_TEST_PROMPT_NAME }} steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: persist-credentials: false - name: Set up Java 17 - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: temurin java-version: 17 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..ab76672 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,101 @@ +name: Quality and supply chain + +on: + workflow_call: + inputs: + checkout_ref: + description: Commit or ref to audit + required: false + type: string + workflow_dispatch: + schedule: + - cron: '17 2 * * 1' + +permissions: + contents: read + +concurrency: + group: quality-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + name: static analysis, licenses, SBOM, vulnerabilities + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ inputs.checkout_ref || github.sha }} + persist-credentials: false + + - name: Set up Java 17 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + with: + distribution: temurin + java-version: 17 + cache: maven + + - name: Build quality evidence + run: >- + ./mvnw -B -ntp -Pquality + -DskipTests + -Djacoco.skip=true + -Dmaven.javadoc.skip=true + clean verify + + - name: Validate quality evidence + shell: bash + run: | + set -euo pipefail + + test -s target/bom.json + test -s target/generated-sources/license/THIRD-PARTY.txt + test -s langfuse-otel-core/target/spotbugsXml.xml + test -s langfuse-otel-spring-boot-starter/target/spotbugsXml.xml + + jq -e ' + .bomFormat == "CycloneDX" + and .specVersion == "1.6" + and .metadata.component.type == "library" + and ((.components // []) | length > 0) + and ((.dependencies // []) | length > 0) + ' target/bom.json > /dev/null + + - name: Audit all dependency vulnerabilities + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: sbom + scan-ref: target/bom.json + scanners: vuln + format: json + output: target/trivy-vulnerabilities.json + exit-code: '0' + severity: UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL + list-all-pkgs: true + hide-progress: true + timeout: 10m + version: v0.72.0 + + - name: Reject high and critical dependency vulnerabilities + run: | + jq -e ' + [ + .Results[]?.Vulnerabilities[]? + | select(.Severity == "HIGH" or .Severity == "CRITICAL") + ] + | length == 0 + ' target/trivy-vulnerabilities.json > /dev/null + + - name: Retain quality evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: quality-evidence-${{ github.run_id }}-${{ github.run_attempt }} + path: | + target/bom.json + target/generated-sources/license/THIRD-PARTY.txt + target/trivy-vulnerabilities.json + langfuse-otel-core/target/spotbugsXml.xml + langfuse-otel-spring-boot-starter/target/spotbugsXml.xml + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9a2e443..d0e782c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,14 +31,14 @@ jobs: release_sha: ${{ steps.version.outputs.release_sha }} version: ${{ steps.version.outputs.version }} steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ inputs.release_tag || github.ref }} fetch-depth: 0 persist-credentials: false - name: Set up Java 17 - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: temurin java-version: 17 @@ -129,10 +129,13 @@ jobs: fi done - for example in examples/spring-ai-example examples/langchain4j-example; do - example_version="$(./mvnw -q -f "$example/pom.xml" -DforceStdout help:evaluate -Dexpression=langfuse-otel.version)" - if [[ "$example_version" != "$pom_version" ]]; then - echo "::error::$example uses langfuse-otel version $example_version instead of $pom_version." + for consumer in \ + examples/spring-ai-example \ + examples/langchain4j-example \ + consumer-tests/core-prompt-consumer; do + consumer_version="$(./mvnw -q -f "$consumer/pom.xml" -DforceStdout help:evaluate -Dexpression=langfuse-otel.version)" + if [[ "$consumer_version" != "$pom_version" ]]; then + echo "::error::$consumer uses langfuse-otel version $consumer_version instead of $pom_version." exit 1 fi done @@ -214,13 +217,13 @@ jobs: runs-on: ubuntu-latest needs: [preflight] steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.release_sha }} persist-credentials: false - name: Set up Java 17 - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: temurin java-version: 17 @@ -229,6 +232,13 @@ jobs: - name: Clean verify run: ./mvnw -B -ntp clean verify + - name: Verify coverage artifacts + run: | + test -s langfuse-otel-core/target/jacoco.exec + test -s langfuse-otel-core/target/site/jacoco/jacoco.xml + test -s langfuse-otel-spring-boot-starter/target/jacoco.exec + test -s langfuse-otel-spring-boot-starter/target/site/jacoco/jacoco.xml + core: name: core release gate (Java ${{ matrix.java }}) runs-on: ubuntu-latest @@ -238,13 +248,13 @@ jobs: matrix: java: [11, 17, 21] steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.release_sha }} persist-credentials: false - name: Set up Java ${{ matrix.java }} - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: temurin java-version: ${{ matrix.java }} @@ -262,10 +272,10 @@ jobs: matrix: include: - java: 17 - spring-ai-version: '1.0.0' + spring-ai-version: '1.0.9' langchain4j-version: '1.0.0' - java: 21 - spring-ai-version: '1.0.0' + spring-ai-version: '1.0.9' langchain4j-version: '1.0.0' - java: 17 spring-ai-version: '1.1.8' @@ -273,20 +283,14 @@ jobs: - java: 21 spring-ai-version: '1.1.8' langchain4j-version: '1.18.0' - - java: 17 - spring-ai-version: '2.0.0' - langchain4j-version: '1.18.0' - - java: 21 - spring-ai-version: '2.0.0' - langchain4j-version: '1.18.0' steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.release_sha }} persist-credentials: false - name: Set up Java ${{ matrix.java }} - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: temurin java-version: ${{ matrix.java }} @@ -304,20 +308,20 @@ jobs: runs-on: ubuntu-latest needs: [preflight] steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.release_sha }} persist-credentials: false - name: Set up Java 17 - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: temurin java-version: 17 cache: maven - name: Install release artifacts locally - run: ./mvnw -B -ntp -DskipTests install + run: ./mvnw -B -ntp -DskipTests -Djacoco.skip=true install - name: Verify Spring AI consumer run: ./mvnw -B -ntp -f examples/spring-ai-example/pom.xml -DskipTests verify @@ -325,11 +329,21 @@ jobs: - name: Verify LangChain4j consumer run: ./mvnw -B -ntp -f examples/langchain4j-example/pom.xml -DskipTests verify + - name: Verify core and prompt client compatibility + run: ./mvnw -B -ntp -f consumer-tests/core-prompt-consumer/pom.xml verify + + quality: + name: release quality and supply chain + needs: [preflight] + uses: ./.github/workflows/quality.yml + with: + checkout_ref: ${{ needs.preflight.outputs.release_sha }} + release-gate: name: release quality gate runs-on: ubuntu-latest if: always() - needs: [preflight, verify, core, starter, consumer-smoke] + needs: [preflight, verify, core, starter, consumer-smoke, quality] permissions: {} steps: - name: Require every release candidate gate @@ -340,11 +354,12 @@ jobs: CORE_RESULT: ${{ needs.core.result }} STARTER_RESULT: ${{ needs.starter.result }} CONSUMER_RESULT: ${{ needs.consumer-smoke.result }} + QUALITY_RESULT: ${{ needs.quality.result }} run: | set -euo pipefail failed=false - for gate in PREFLIGHT VERIFY CORE STARTER CONSUMER; do + for gate in PREFLIGHT VERIFY CORE STARTER CONSUMER QUALITY; do result_variable="${gate}_RESULT" result="${!result_variable}" echo "$gate: $result" @@ -386,13 +401,13 @@ jobs: exit 1 fi - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.release_sha }} persist-credentials: false - name: Set up Java 17 and signing - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: temurin java-version: 17 diff --git a/CHANGELOG.md b/CHANGELOG.md index e7518ff..a517f41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,80 +3,74 @@ ## [Unreleased] - 0.2.0 Production Preview ### Added -- Application-owned OpenTelemetry mode via `LangfuseOtel.externalBuilder(OpenTelemetry)` -- Spring auto-configuration reuses a unique application `OpenTelemetry` bean without owning its lifecycle -- `ContentCapturePolicy` with independent input/output opt-in, `ContentRedactor`, and an 8,192-unit post-redaction limit -- `ExceptionCapturePolicy` with type-only defaults and independent redacted message/stack-trace opt-ins -- Explicit Spring `langfuse.otel-mode=auto|external|standalone` ownership selection -- Immutable `LangfuseTraceContext` propagation through OpenTelemetry Context and Reactor Context -- Completion-aware `@ObserveGeneration` support for `CompletionStage`, Reactor `Mono`, and Reactor `Flux` -- Type-preserving automatic model proxies with provider extension method delegation and idempotent advice detection -- Per-subscription, reference-counted Reactor Scheduler propagation leases for Spring AI streams and Reactor-returning `@ObserveGeneration` methods -- `ReactorContextPropagation.wrap(Publisher)` for raw-thread source signals and provider-side Reactive Streams operators -- `LangChain4jStreamingContext` terminal-aware fixed snapshots, task/executor wrappers preserving scheduled capabilities, and listener-attribute accessors for provider integration boundaries -- Maven Wrapper with a pinned distribution checksum -- Reproducible archive metadata through a fixed Maven build output timestamp -- Contract tests for SDK ownership, scope restoration, safe content defaults, reactive context isolation, streaming lifecycle, and standalone OTLP transport security + +- Explicit `auto`, `external`, and `standalone` OpenTelemetry ownership modes +- Safe-by-default content and exception capture policies with independent redactors and limits +- Immutable request metadata propagation through OpenTelemetry and Reactor contexts +- Completion-aware `@ObserveGeneration` support for `CompletionStage`, `Mono`, and `Flux` +- Provider-facing Reactor and LangChain4j context adapters for owned scheduling boundaries +- Type-preserving automatic model proxies with provider extension-method delegation +- Immutable runtime status plus optional Actuator health and Micrometer signals +- Reproducible artifacts, a checksum-pinned Maven Wrapper, and release gates for coverage, + dependency convergence, static analysis, licenses, SBOM vulnerabilities, 0.1.1 API + compatibility, warning-free Javadocs, framework matrices, and consumer builds +- Standalone OTLP transport contract coverage for payloads, hierarchy, headers, endpoint safety, + and redirect credential handling ### Changed -- Automatic Spring AI, LangChain4j, and `@ObserveGeneration` content capture is metadata-only by default -- Automatic and direct wrapper exception capture records only the exception type by default -- HTTP Principal and session ID extraction now require independent explicit opt-in -- Spring AI streaming spans and accumulators are created per subscription with `Flux.deferContextual` -- Automatic model BeanPostProcessors preserve proxyable provider concrete types and declared extension interfaces -- Automatic model BeanPostProcessors participate in Spring's early-singleton-reference phase so an explicitly enabled circular dependency and the final singleton share one instrumented proxy -- Final/non-proxyable model beans are kept unchanged and reported as uninstrumented instead of being replaced by an incompatible decorator -- Explicit `@ObserveGeneration` model methods take bean-level precedence over automatic model instrumentation -- WebFlux request metadata no longer relies on a request-lifetime ThreadLocal -- CI runs `./mvnw -B -ntp clean verify`, including packaging, sources, and Javadoc generation -- Release validation now requires tag/POM/docs/examples consistency plus the Java/framework and consumer gates; Central publishing stops for manual approval -- Live Langfuse CI reports an explicit opt-in status and fails on missing credentials once enabled -- Standalone hosts are validated and require HTTPS; the development-only HTTP opt-in is restricted to loopback hosts + +- Automatic instrumentation no longer captures model input or output by default +- Exception message and stack capture, HTTP Principal export, and session ID export are opt-in +- Spring AI streaming state and bounded output buffers are created per subscription +- Non-proxyable model beans remain unchanged and explicit model-method annotations take precedence + over automatic instrumentation +- The Boot 3 line now uses Spring Boot 3.5.16, Spring AI 1.0.9, OpenTelemetry 1.62.0, and Netty + 4.1.136.Final; CI also covers Spring AI 1.1.8 and LangChain4j 1.18.0 +- `0.2.x` remains the Boot 3/Spring AI 1 line; Boot 4/Spring AI 2 moves to `0.3.x` +- Maven Central publication stops at `VALIDATED` for manual approval before the GitHub release is + published ### Fixed -- Re-subscribing to a Spring AI stream no longer shares a span or output accumulator -- Unsubscribed streams no longer create orphan spans -- Stream completion, error, and cancellation end each span exactly once -- Enabled streaming output buffers are bounded before terminal redaction/export -- Raw streaming outputs that exceed the pre-redaction bound are dropped instead of exporting a potentially unsafe prefix -- Stack-only exception capture no longer leaks messages from the throwable, causes, or suppressed exceptions -- Legacy context setters and `clear()` override scoped immutable metadata for subsequent synchronous spans -- Embedding dimension and image-edit provider methods are delegated without behavioral regression -- Synchronous provider spans created inside raw wrappers inherit the wrapper observation as parent -- `SpanGuard` restores the previous OTel Scope even if the raw span was ended first -- Cleaner fallback no longer closes a thread-affine Scope from the Cleaner thread -- Explicit parent contexts no longer inherit unrelated current-request metadata -- Existing application-owned OpenTelemetry SDKs are no longer duplicated or shut down by the library -- Automatic BeanPostProcessor instrumentation no longer exposes streaming-only LangChain4j beans as synchronous `ChatModel` candidates -- `CompletionStage` observations retain the original stage identity and end only at success, failure, or cancellation -- Reactor annotation observations create no span before subscription and isolate repeated subscriptions -- Spring AI provider work started while the source subscription is entered inherits the wrapper observation as parent -- Reactor tasks scheduled under an active instrumented subscription inherit its wrapper context; terminal lease closure prevents already-decorated delayed tasks from restoring an ended observation and preserves independently keyed hooks -- Raw Publisher signals, request, and cancel restore the per-subscription context without requiring a Reactor Scheduler, while terminal and concurrent in-flight boundaries close their lease without thread-context leakage -- Spring AI stream assembly and Reactor terminal callbacks retain the full wrapper context until downstream terminal delivery returns -- LangChain4j streaming callbacks and model listeners restore the wrapper observation and immutable request metadata without retaining a cross-thread Scope -- LangChain4j late, queued, and periodic executor work no longer restores a wrapper span after terminal cleanup; reentrant cancellation no longer deadlocks with a provider terminal acknowledgement -- Model methods no longer emit duplicate automatic and `@ObserveGeneration` spans + +- Streaming completion, error, cancellation, and re-subscription no longer leak or reuse spans +- Oversized raw streaming output and stack-only exception capture no longer expose unsafe prefixes + or throwable messages +- Spring AI scheduler/source boundaries and LangChain4j callbacks/listeners restore the correct + observation and request metadata without retaining a cross-thread scope +- Provider spans inherit the wrapper observation, and scope restoration remains correct when a raw + span ends first +- Application-owned OpenTelemetry SDKs are no longer duplicated, flushed, or shut down by the + library +- Provider embedding/image methods and proxyable concrete or extension types retain their behavior; + streaming-only LangChain4j beans are not exposed as synchronous models +- Annotated future and Reactor methods end at the real terminal event without duplicate model spans +- Spring AI document and bulk embedding adapters reject invalid `null` delegate results and record + the tracing error +- Consumer artifact installation no longer runs a zero-test JaCoCo coverage check ## [0.1.1] - 2026-05-12 ### Added + - `langfuse-otel-core`: `LangfuseContextSpanProcessor` — auto-propagates userId, sessionId, tags, environment to all child spans - `langfuse-otel-core`: Javadoc for all public API classes - `langfuse-otel-spring-boot-starter`: `LangfuseReactiveContextFilter` for WebFlux applications - CI compatibility matrix: Spring AI 1.0.0 / 1.1.6 / 2.0.0-M6, LangChain4j 1.0.0 / 1.14.1 ### Fixed + - `completion_start_time` format: epoch millis → ISO 8601 (Langfuse OTLP spec) - LangChain4j streaming: `StringBuilder` → `StringBuffer` for thread safety - `LangfuseReactiveContextFilter`: session ID race condition (`subscribe()` → `Mono.zip()`) ### Changed + - Surefire `excludedGroups` extracted to Maven property for CLI override ## [0.1.0] - 2026-05-08 ### Added + - `langfuse-otel-core`: Builder pattern OTel SDK wrapper (`LangfuseOtel`) - `langfuse-otel-core`: `LangfuseTrace`, `LangfuseGeneration`, `LangfuseSpan` with callback, try-with-resources, and manual `end()` APIs - `langfuse-otel-core`: `LangfuseContext` ThreadLocal propagation for userId, sessionId, tags, environment diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e77663c..3482c47 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,101 +1,81 @@ # Contributing -Thanks for your interest in contributing to langfuse-otel-java! +Thanks for contributing to langfuse-otel-java. -## Development Setup +## Development setup -### Prerequisites +Use Java 17 or newer. The Maven Wrapper pins the Maven version used by CI, so a system Maven +installation is not required. On Windows, replace `./mvnw` with `mvnw.cmd`. -- Java 17+ -- A Langfuse account (cloud or self-hosted) for integration tests +For a fast unit-test run: -The repository Maven Wrapper pins the Maven distribution used by CI, so a system Maven installation is not required. On Windows, replace `./mvnw` with `mvnw.cmd`. +```bash +./mvnw -B -ntp test +``` -### Build +Before opening a pull request: ```bash -./mvnw -B -ntp compile +./mvnw -B -ntp clean verify ``` -### Run Tests +`clean verify` runs unit and packaged-JAR tests, coverage checks, warning-free Javadocs, and +binary/source API compatibility against `0.1.1`. Coverage reports are written under each module's +`target/site/jacoco` directory. -Unit tests (no external dependencies): +The slower quality profile adds static analysis and dependency-license checks while producing the +CycloneDX SBOM used by CI: ```bash -./mvnw -B -ntp test +./mvnw -B -ntp -Pquality -DskipTests -Djacoco.skip=true \ + -Dmaven.javadoc.skip=true clean verify ``` -Integration tests (requires Langfuse API keys): +Live integration tests require Langfuse credentials: ```bash export LANGFUSE_PUBLIC_KEY=pk-lf-... export LANGFUSE_SECRET_KEY=sk-lf-... export LANGFUSE_HOST=https://cloud.langfuse.com -./mvnw -B -ntp test -pl langfuse-otel-core -am -DexcludedGroups= -Dgroups=integration +./mvnw -B -ntp test -pl langfuse-otel-core -am \ + -DexcludedGroups= -Dgroups=integration ``` -CI only runs these live tests when the repository variable `LANGFUSE_INTEGRATION_ENABLED` is explicitly set to `true`. When enabled, missing public/secret keys fail the integration status job instead of silently reporting a successful test run. +CI runs live export tests only when `LANGFUSE_INTEGRATION_ENABLED=true`. Once enabled, missing +credentials fail the integration status job instead of silently skipping it. -### Project Structure +## Repository layout -``` -langfuse-otel-java/ -├── langfuse-otel-core/ # Core library (Java 11+) -│ └── io.github.chomingi.langfuse.otel -│ ├── LangfuseOtel # Main entry point (Builder) -│ ├── LangfuseTrace # Trace wrapper -│ ├── LangfuseGeneration # LLM generation wrapper -│ ├── LangfuseSpan # General span wrapper -│ ├── LangfuseContext # OTel/legacy synchronous context bridge -│ ├── LangfuseTraceContext # immutable async request metadata -│ ├── LangfuseAttributes # OTel attribute constants -│ └── LangfusePromptHelper # Prompt integration -│ -├── langfuse-otel-spring-boot-starter/ # Spring Boot starter (Java 17+) -│ └── io.github.chomingi.langfuse.otel.spring -│ ├── LangfuseOtelAutoConfiguration -│ ├── LangfuseOtelProperties -│ ├── SpringAiAutoConfiguration # Registers Spring AI BeanPostProcessors -│ ├── LangChain4jAutoConfiguration # Registers LangChain4j BeanPostProcessors -│ ├── TracingSpringAiChatModel # ChatModel wrapper (sync + streaming) -│ ├── TracingSpringAiEmbeddingModel -│ ├── TracingSpringAiImageModel -│ ├── TracingLangChain4jChatModel -│ ├── TracingStreamingLangChain4jChatModel -│ ├── TracingLangChain4jEmbeddingModel -│ ├── TracingLangChain4jImageModel -│ ├── LangfuseContextFilter # HTTP context propagation -│ └── annotation/ -│ ├── ObserveGeneration # @ObserveGeneration -│ └── ObserveGenerationAspect -│ -└── examples/ # Example applications - ├── spring-ai-example/ - └── langchain4j-example/ -``` +| Path | Purpose | +|------|---------| +| `langfuse-otel-core` | Framework-neutral Java 11 tracing API and standalone exporter | +| `langfuse-otel-spring-boot-starter` | Java 17 Spring AI and LangChain4j auto-configuration | +| `examples` | Consumer builds for both supported adapters | ## Guidelines -- Open an issue before starting work on a new feature -- Keep PRs focused — one feature/fix per PR -- Add tests for new functionality -- Follow existing code style (no Lombok, no excessive comments) -- Core module must stay Java 11 compatible -- Spring Boot starter targets Java 17+ +- Open an issue before starting a new feature. +- Keep pull requests focused and add tests for changed behavior. +- Keep the core module compatible with Java 11 and the starter with Java 17. +- Follow the existing style: no Lombok and no comments that repeat the code. +- Do not commit credentials or captured model data. -## Adding a New Auto-Instrumentation +## Adding 0.2.x auto-instrumentation -To add support for a new model type or framework: +The current Boot 3 adapter line uses tracing decorators and type-preserving Spring proxies: -1. Create a `Tracing*Model.java` wrapper implementing the model interface (Decorator pattern) -2. Create a `*BeanPostProcessor.java` to wrap beans at initialization -3. Register the BeanPostProcessor in the appropriate `*AutoConfiguration.java` -4. Add the framework as an `` dependency in `pom.xml` if not already present -5. Add unit tests with stub models and `OpenTelemetryExtension` +1. Add a `Tracing*Model` decorator for the framework model interface. +2. Register it through the appropriate auto-configuration and shared model post-processor. +3. Keep framework dependencies optional. +4. Preserve provider extension methods and concrete injection where proxying is safe. +5. Cover success, setup failure, delegate failure, and any asynchronous terminal paths. -For streaming models, create state per subscription and use raw OTel `Span` (without `makeCurrent()`) to avoid thread-context corruption across async callbacks. Propagate request metadata with Reactor/OTel Context. See DESIGN.md #13 and #17. +Streaming adapters create state per subscription or invocation and use raw OpenTelemetry spans +with short-lived scopes at callback boundaries. See the +[asynchronous lifecycle design](DESIGN.md#asynchronous-observation-and-context-lifecycle). ## Releasing -Maintainers must follow [RELEASING.md](RELEASING.md). In particular, a Maven Central `VALIDATED` candidate is not public, and a Central upload job must never be rerun without first checking the Publisher Portal. +Maintainers must follow [RELEASING.md](RELEASING.md). A Maven Central `VALIDATED` candidate is not +public, and a Central upload job must not be rerun before checking the Publisher Portal. diff --git a/DESIGN.md b/DESIGN.md index ce2ef06..352a181 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,259 +1,111 @@ -# Design Decisions +# Design -Key architectural and implementation decisions for langfuse-otel-java. +This document records the decisions that shape the public API and its runtime behavior. Smaller +implementation details belong in code, tests, and Javadocs. ---- +## OpenTelemetry as the transport -## 1. OTel-based, not direct Langfuse API +The library creates Langfuse-compatible OpenTelemetry spans instead of calling the ingestion API +directly. This lets an application route the same trace through its existing SDK or Collector. +Langfuse documents this path in its +[OpenTelemetry integration guide](https://langfuse.com/integrations/native/opentelemetry). -We wrap OpenTelemetry SDK instead of calling the Langfuse ingestion API directly. +## Fail-safe observability -**Why:** Langfuse's maintainer explicitly recommends OTel for Java tracing ([langfuse-java #2](https://github.com/langfuse/langfuse-java/issues/2#issuecomment-2706738123), [#24](https://github.com/langfuse/langfuse-java/issues/24#issuecomment-2698403123)). OTel is vendor-neutral — users can send traces to Langfuse and other backends (Datadog, Jaeger) simultaneously. The Langfuse OTLP endpoint handles `gen_ai.*` semantic conventions natively, so we don't need to reinvent trace data modeling. +`LangfuseOtel.builder()` defaults to `failSafe(true)`. Missing keys or a standalone setup failure +produce an observable no-op fallback instead of stopping the host application. Callers that want +strict construction can disable fail-safe mode. -**Alternative considered:** Direct API calls (like [yuvenhol/langfuse_java](https://github.com/yuvenhol/langfuse_java)). Rejected because it creates Langfuse lock-in and duplicates what OTel already does well. +This does not hide ambiguous ownership. Spring `external` mode requires one application +`OpenTelemetry` bean, and `auto` mode refuses to guess when multiple candidates are equally valid. ---- +## Synchronous span lifecycle -## 2. Three API styles (callback, try-with-resources, manual end) +The core API supports callbacks, try-with-resources, and explicit `end()`. They share one contract: +each wrapper retains an OpenTelemetry `Scope`, so it must close on its creating thread and in +reverse creation order. -```java -// Callback -langfuse.trace("flow", trace -> { ... }); +An abandoned wrapper registers a `Cleaner` action that warns and ends the span. The cleaner never +closes the originating thread's `Scope`; it is a last-resort span cleanup, not a substitute for a +correct close. -// Try-with-resources -try (LangfuseTrace trace = langfuse.trace("flow")) { ... } +## OpenTelemetry ownership -// Manual -LangfuseTrace trace = langfuse.trace("flow"); -trace.end(); -``` +`LangfuseOtel.builder()` creates and owns a dedicated SDK and exporter. +`LangfuseOtel.externalBuilder(openTelemetry)` uses an application-owned SDK and creates no +exporter. In external mode, export routing, resources, batching, flushing, and shutdown remain the +application's responsibility; the library's `flush()` and `close()` do not affect that SDK. -**Why:** Different Java codebases have different styles. Callback is cleanest for new code. Try-with-resources is idiomatic Java. Manual `end()` is necessary for async flows where scope doesn't align with method boundaries. Forcing one style on an open-source library limits adoption. +A process should normally own one OpenTelemetry SDK. Making ownership explicit avoids duplicate +exports, split traces, and shutting down infrastructure owned by the host application. -**Trade-off:** Three patterns means more API surface to maintain and test. We accept this because the implementation cost is low (callback just wraps TWR internally) and the user benefit is high. +## Optional prompt client ---- +Prompt management uses the optional `com.langfuse:langfuse-java` dependency. Public entry points +accept the client as `Object` so applications that do not use prompt management can load the core +API without that optional type on their classpath. The trade-off is runtime validation instead of +compile-time type safety for this feature. -## 3. failSafe defaults to true (no-op on missing keys) +## Type-preserving model instrumentation -```java -LangfuseOtel.builder().build() // no keys → returns no-op, never throws -``` +The starter installs programmatic Spring proxies around supported model beans. Model calls route +through tracing decorators, while provider extension methods continue to target the original +model. A safely proxyable class keeps its concrete type and declared interfaces. -**Why:** An observability library must never crash the host application. If API keys are misconfigured, the app should run normally — just without tracing. This is critical for production safety. Users who want strict validation can use `.failSafe(false)`. +Final classes, final model methods, or externally callable final extension methods cannot be +intercepted without changing behavior. The starter therefore leaves those beans unchanged and +logs the instrumentation gap. Existing JDK proxies retain only the interfaces they already expose. -**Precedent:** Honeycomb OTel Java SDK, Datadog Java tracer — both silently degrade when misconfigured. +If a model method declares `@ObserveGeneration`, explicit annotation-based tracing takes +precedence for that entire model bean. Automatic model instrumentation is skipped so one call does +not create nested duplicate observations. ---- +## Privacy-first capture -## 4. SpanGuard with java.lang.ref.Cleaner +Automatic instrumentation records metadata but not model input, output, exception messages, or +stack traces by default. Applications opt into each data direction independently and can provide +separate content and exception redactors. -When a span is not properly closed, `SpanGuard` logs a WARNING and closes the span via GC finalization. +No redactor is installed implicitly. Enabled values with no redactor are exported unchanged up to +their configured limit. Multiple redactors, redactor failures, and `null` redactor results fail +closed for the affected value. -**Why:** Forgetting to close a span (especially with the manual `end()` pattern) is a common mistake. Without SpanGuard, the span stays open forever on the OTel context stack, silently corrupting parent-child relationships for all subsequent spans on that thread. The Cleaner-based approach provides a safety net during development without runtime overhead in the normal (properly closed) path. +Streaming output is bounded before terminal redaction. A raw completion that crosses the limit is +dropped rather than exporting a prefix that might cut through a sensitive pattern. Explicit core +`.input()` and `.output()` calls remain direct because the caller has already chosen to capture +that value. -**Limitation:** GC-based cleanup is unreliable — the Cleaner may not fire before JVM shutdown. This is acceptable because SpanGuard is a safety net, not the primary mechanism. The real fix is the WARNING log that tells the developer to use `try-with-resources` or `end()`. +## Asynchronous observation and context lifecycle ---- +Asynchronous wrappers use raw OpenTelemetry spans and never retain a thread-affine `Scope` for the +operation's lifetime. The complete invocation context is restored only around same-thread method, +subscription, signal, scheduled-task, or callback boundaries, and each short-lived scope closes on +the thread that opened it. -## 5. AtomicBoolean for close() guard +Spring AI streams and Reactor-returning `@ObserveGeneration` methods create state per subscription. +A terminal-bound scheduler lease covers tasks scheduled while that subscription context is active. +The final wrapper also restores raw Reactive Streams signals, request, and cancel. +`ReactorContextPropagation.wrap(rawPublisher)` lets a provider adapter move the boundary next to a +raw source when its own upstream operators also need the context. -```java -private final AtomicBoolean closed = new AtomicBoolean(false); +LangChain4j callbacks and model listeners restore the invocation context automatically. Provider +adapters that own task submission can use `LangChain4jStreamingContext.wrap(...)`, +`taskWrapping(...)`, or a fixed per-request `Snapshot`. These adapters do not claim to reach an +opaque provider-owned executor, and they retain no global request registry. -public void close() { - if (closed.compareAndSet(false, true)) { - cleanable.clean(); - } -} -``` +`CompletionStage` observations attach a terminal side effect and return the original stage. +Reactor observations create one span per subscription and end it on completion, error, or +cancellation. Atomic terminal guards prevent concurrent signals from ending a span twice. -**Why:** `close()` delegates to `cleanable.clean()` which calls `scope.close()` + `span.end()`. Calling `scope.close()` twice corrupts the OTel context stack. While the JDK Cleaner guarantees at-most-once execution, `AtomicBoolean` provides an additional thread-safe guard for the case where `close()` and the Cleaner daemon run concurrently. +The `0.2.x` compatibility claim is JVM-only. Reflective compatibility paths do not imply Spring +AOT or GraalVM native-image support. -**Why not volatile boolean:** `if (!closed) { closed = true; }` is a check-then-act race. Two threads can both see `closed == false` and both enter the block. +## Standalone transport safety ---- +Standalone mode accepts only absolute HTTP(S) hosts without user-info, query, or fragment +components. HTTPS is required by default because OTLP authentication uses a Basic +`Authorization` header. Plaintext HTTP requires an explicit development-only option and is limited +to `localhost` or a literal loopback address. -## 6. close() delegates entirely to cleanable.clean() - -```java -public void close() { - if (closed.compareAndSet(false, true)) { - cleanable.clean(); // this does scope.close() + span.end() - // NOT: scope.close(); span.end(); — would cause double-close - } -} -``` - -**Why:** Earlier versions called `cleanable.clean()` AND `scope.close()` + `span.end()` in `close()`. This caused double-close because `clean()` already invokes `CleanAction.run()` which does `scope.close()` + `span.end()`. Double `scope.close()` corrupts the OTel context stack by popping the wrong parent context. - ---- - -## 7. AbstractLangfuseSpan base class - -`LangfuseTrace`, `LangfuseGeneration`, `LangfuseSpan` all extend `AbstractLangfuseSpan`. - -**Why:** `recordException()`, `close()`, `end()`, `getSpan()`, and SpanGuard registration were copy-pasted across all three classes (~40 lines each). A bug fix in one had to be replicated in all three. The base class eliminates this duplication while keeping subclass-specific behavior (e.g., Generation has `model()`, Trace has `userId()`). - ---- - -## 8. gen_ai.operation.name defaults to "chat" but is overridable - -```java -new LangfuseGeneration(tracer, "my-gen") // default: "chat" -gen.operationName("embeddings") // override -``` - -**Why:** `gen_ai.operation.name` determines how Langfuse classifies the observation — `"chat"` → GENERATION, `"embeddings"` → EMBEDDING. Most LLM calls are chat completions, so defaulting to `"chat"` is the right 80/20. For embeddings, image generation, etc., users override via `operationName()`. - ---- - -## 9. Tags as OTel array attribute, not comma-separated string - -```java -span.setAttribute(AttributeKey.stringArrayKey("langfuse.trace.tags"), Arrays.asList(tags)); -``` - -**Why:** Langfuse's OTel ingestion endpoint parses array attributes natively. Comma-separated strings are ambiguous if a tag itself contains a comma. OTel's attribute API supports typed arrays — using them is strictly correct. - ---- - -## 10. Decorator wraps all ChatModel overloads via interface delegation - -`TracingSpringAiChatModel` implements `ChatModel`, which means `call(String)` and `call(Message...)` (default methods that delegate to `call(Prompt)`) are automatically covered. The tracing wrapper only needs to override `call(Prompt)` and `stream(Prompt)` — the default methods route through them. - -**Why:** Earlier AOP-based approaches required explicit pointcut matching for each overload. The Decorator pattern avoids this entirely — implementing the interface guarantees all entry points are covered. - ---- - -## 11. LangfuseGeneration constructor is public - -Normally, `LangfuseGeneration` should only be created via `trace.generation("name")`. The constructor is public because tracing wrappers (in the separate `spring-boot-starter` module) need to create instances directly: `new LangfuseGeneration(tracer, name)`. - ---- - -## 12. Object type for langfuseClient - -```java -public Builder langfuseClient(Object langfuseClient) { ... } -public LangfusePromptHelper prompt(Object langfuseClient, String promptName) { ... } -``` - -**Why:** `com.langfuse:langfuse-java` is an optional dependency. Using the concrete type `LangfuseClient` in the public API would cause `NoClassDefFoundError` for users who don't have it on their classpath — even if they never call `prompt()`. Using `Object` defers the class loading to the point of use, where we guard it with `Class.forName()` check. - -**Trade-off:** No compile-time type safety. Users can pass any object and get a `ClassCastException`. This is acceptable because the prompt API is an advanced feature, and the error message is clear. - ---- - -## 13. Raw OTel Span for streaming (no lifetime Scope) - -Streaming tracing wrappers (`TracingSpringAiChatModel.stream()`, `TracingStreamingLangChain4jChatModel`) use the raw OTel `Span` API instead of `LangfuseGeneration`. - -```java -Context parent = Context.current(); -Span span = tracer.spanBuilder(name) - .setParent(parent) - .setSpanKind(SpanKind.CLIENT) - .startSpan(); -Context invocationContext = parent.with(span); -// makeCurrent() is used only for a same-thread delegate, subscription, or callback boundary. -``` - -**Why:** `AbstractLangfuseSpan` calls `span.makeCurrent()` in its constructor, pushing the span onto the thread-local OTel context stack. This is correct for synchronous flows where the span opens and closes on the same thread. For streaming, responses arrive on different threads (Reactor schedulers for Spring AI's `Flux`, callback threads for LangChain4j's `StreamingChatResponseHandler`). Calling `makeCurrent()` on the originating thread and `scope.close()` on a callback thread corrupts the context stack. - -**How it works:** The full invocation context (wrapper span plus immutable Langfuse metadata) is restored only around a synchronous delegate call, source subscription, Reactor-scheduled task, or LangChain4j callback/listener invocation. Each short-lived Scope is closed on the same thread and boundary; no Scope is retained for the asynchronous lifetime. The span is ended in terminal signals (`doOnComplete`/`doOnError`/`doOnCancel` for Flux, `onCompleteResponse`/`onError`/cancellation for callbacks). An `AtomicBoolean` guard prevents double-end from concurrent terminal signals. - -**Trade-off:** Some code duplication — the attribute-setting logic from `LangfuseGeneration` is replicated as helper methods in the streaming wrappers, since `LangfuseGeneration` retains the Scope created by its constructor for its lifetime. This is acceptable because extracting a shared utility would require modifying the core module for a starter-only concern. - ---- - -## 14. Type-preserving BeanPostProcessor proxies for auto-instrumentation - -The starter uses a shared `SmartInstantiationAwareBeanPostProcessor` base to install a programmatic Spring AOP proxy around model beans. The proxy routes only framework model methods through the tracing decorators and delegates provider extension methods directly to the original target. - -```java -abstract class AbstractModelBeanPostProcessor - implements SmartInstantiationAwareBeanPostProcessor { - public Object getEarlyBeanReference(Object bean, String beanName) { - return instrumentAndRememberEarlyTarget(bean, beanName); - } - - public Object postProcessAfterInitialization(Object bean, String beanName) { - return reconcileEarlyReferenceOrInstrument(bean, beanName); - } -} -``` - -**Why:** Replacing a provider bean with an unrelated interface decorator removes its concrete type, provider extension interfaces, and any additional model roles. It also made a streaming-only LangChain4j bean appear to implement synchronous `ChatModel`. A class proxy keeps the original assignability and interface set, while a shared advisor marker makes repeated BeanPostProcessor passes idempotent. - -**Fail-safe boundary:** A final provider class, final model method, or externally callable final extension method cannot be safely intercepted/delegated with a type-preserving class proxy. In that case the original bean is returned unchanged and a warning identifies the instrumentation gap. Existing JDK proxies are wrapped through their existing interface set. The longer-term adapter direction remains framework-native Spring AI observations and LangChain4j listeners where their versioned APIs provide equivalent coverage. - -When singleton circular references are explicitly enabled, the shared post-processor creates the model proxy in `getEarlyBeanReference`, records the ultimate target by bean name, and avoids adding a second model proxy in `postProcessAfterInitialization`. Spring can therefore promote the one early proxy as the final singleton, preserving identity and one advice chain. This does not make a final or otherwise non-proxyable model instrumentable; the existing fail-safe boundary still applies. - -**Explicit annotation precedence:** If the ultimate model target, an inherited model implementation, or one of its model interfaces declares `@ObserveGeneration` on a model method, automatic model instrumentation is skipped for the entire bean. This guarantees one observation rather than nested automatic and annotation spans; each model entry point that still needs tracing must be annotated explicitly. Non-model service annotations do not affect model proxying. - ---- - -## 15. Explicit OpenTelemetry ownership - -`LangfuseOtel.builder()` is standalone mode and owns the SDK/exporter it creates. `LangfuseOtel.externalBuilder(openTelemetry)` is application-owned mode and creates no SDK or exporter. - -**Why:** A process should normally have one OpenTelemetry SDK. Creating another SDK inside an instrumentation library can duplicate telemetry, split traces, compete for resources, and shut down infrastructure owned by the host application. - -**Lifecycle contract:** `flush()` and `close()` affect only an internally owned SDK. They are intentional no-ops in external mode. Resource attributes, export routing, batching, and shutdown are then the application's or Collector's responsibility. - ---- - -## 16. Metadata-only automatic content capture - -Automatic framework instrumentation does not attach model input or output by default. Users opt in independently for each direction through `ContentCapturePolicy` or Spring properties. Enabled values pass through a user redactor and a finite post-redaction length limit. - -**Why:** Prompts, completions, embedding text, and image prompts frequently contain personal data, credentials, or proprietary content. Observability metadata should be useful without silently expanding the application's data boundary. - -**Compatibility:** Explicit core fluent calls to `.input()` and `.output()` remain direct because making such a call is itself an intentional capture decision. The policy governs only automatic instrumentation. - ---- - -## 17. Immutable async request context - -`LangfuseTraceContext` is immutable and can be stored in OpenTelemetry Context or Reactor Context. The span processor reads the supplied `parentContext` first. Servlet filters use a scoped OTel context; WebFlux filters store request metadata in Reactor Context rather than holding a ThreadLocal open for the request lifetime. - -**Why:** ThreadLocal values do not follow scheduler switches and can leak between concurrent reactive requests. An immutable subscription context preserves request isolation and makes the propagation boundary explicit. - ---- - -## 18. Type-only exception capture by default - -Automatic instrumentation and direct span wrappers retain `exception.type` while omitting message and stack trace by default. `ExceptionCapturePolicy` enables each detail independently, applies an application redactor, and enforces a finite post-redaction limit. - -**Why:** Provider exceptions can include response bodies, prompt fragments, URLs, and credentials. Treating exception details as harmless metadata would bypass the same privacy boundary used for model input and output. - ---- - -## 19. Bounded asynchronous context bridges - -Raw-span wrappers retain no lifetime Scope. Spring AI streams and Reactor-returning `@ObserveGeneration` methods acquire a per-subscription scheduler lease. A keyed `Schedulers.onScheduleHook` captures tasks only while the lease-bearing invocation context is current. Each task checks the lease again at execution time; completion, error, or cancellation closes the lease idempotently after signal boundaries already admitted before terminal have returned, and the last lease removes only this library's keyed hook. Thus a task that starts after termination cannot restore an ended observation, already-running work has an explicit in-flight meaning, and unrelated Reactor hooks remain installed. - -The final automatic source wrapper also restores raw Reactive Streams signals, `request`, and `cancel`, even when a source uses a plain thread rather than a Reactor Scheduler. That wrapper is downstream of any operators a provider already assembled. `ReactorContextPropagation.wrap(rawPublisher)` therefore exposes a public per-subscription boundary that a provider can place immediately above its raw source when its own upstream `map`/`filter` callbacks must see the observation context. No Reactive Streams adapter can instrument arbitrary provider code that runs before one of those boundary calls. - -The LangChain4j streaming wrapper makes the full invocation context current around `doChat` and every user callback. Its listener adapter also carries the captured context in listener attributes. Provider code that owns its scheduling boundary can use submission-time `LangChain4jStreamingContext.wrap(...)`/`taskWrapping(...)`, or retain a fixed `Snapshot` per request when submission happens later from an unscoped control thread. The adapters preserve `ExecutorService` and `ScheduledExecutorService` capabilities. A snapshot atomically admits each wrapper execution against terminal cleanup: work admitted later still runs without the ended span, while already in-flight work finishes under its captured context without delaying span completion. No global request registry is retained. The generic SPI cannot reach an opaque provider-owned executor. - -The supported compatibility matrix is JVM-only. LangChain4j 1.18 cancellation is adapted reflectively to keep the starter compatible across API versions; AOT/native-image support is deliberately not claimed until runtime hints and a native test matrix exist. Reflection failure is nonfatal and leaves the provider callback path operational. - ---- - -## 20. HTTPS-only standalone transport - -Standalone mode accepts only absolute HTTP(S) host URIs with a network host and without user-info, query, or fragment components. HTTPS is mandatory by default. Plaintext HTTP requires the deliberately named development-only builder or Spring property opt-in. - -**Why:** Standalone OTLP authentication uses a Basic `Authorization` header. Accepting an accidental plaintext, credential-bearing, or ambiguous URI would expose credentials or route traces somewhere other than the configured origin. A loopback `HttpServer` contract test covers the final endpoint path, authentication and ingestion headers, serialized span name, and cross-origin redirect credential stripping. - ---- - -## 21. Completion-aware annotated observations - -`@ObserveGeneration` uses a raw span rather than `LangfuseGeneration` for asynchronous results. The observation is current during the synchronous method body only; Reactor results additionally restore it for source subscription and tasks scheduled while the active per-subscription lease context is current. No thread-affine Scope is held across the asynchronous lifetime. A `CompletionStage` attaches a side-effect callback and returns the original stage unchanged; Reactor creates one observation per subscription and ends it on complete, error, or cancel with an atomic terminal guard. - -On framework model beans, an explicit model-method annotation has bean-level precedence over automatic model proxy instrumentation so the two mechanisms never emit nested duplicate generation spans. - -**Why:** Moving a `LangfuseGeneration.end()` call to an arbitrary callback thread would close the Scope created on the invocation thread and corrupt both threads' context stacks. Raw spans separate observation lifetime from thread-affine Scope lifetime. Per-subscription Reactor state also prevents orphan spans for publishers that are never subscribed. +Contract tests cover the final OTLP path, authentication and ingestion headers, serialized spans, +and redirect refusal. diff --git a/MIGRATION-0.2.md b/MIGRATION-0.2.md index 1b22f78..b5c7698 100644 --- a/MIGRATION-0.2.md +++ b/MIGRATION-0.2.md @@ -1,10 +1,12 @@ # Migrating from 0.1.x to 0.2 -0.2 changes automatic instrumentation defaults to make production data boundaries and OpenTelemetry ownership explicit. Review these items before upgrading. +Version 0.2 makes data capture and OpenTelemetry ownership explicit. Review the changes below +before upgrading. -## 1. Model content is no longer captured automatically +## Model content is opt-in -Spring AI, LangChain4j, streaming, embedding, image, and `@ObserveGeneration` wrappers now omit input and output unless enabled independently: +Automatic Spring AI, LangChain4j, embedding, image, streaming, and `@ObserveGeneration` tracing no +longer records input or output unless each direction is enabled: ```yaml langfuse: @@ -14,11 +16,15 @@ langfuse: max-length: 8192 ``` -Capture remains usable without a `ContentRedactor`, but zero beans means identity behavior: enabled content is exported unchanged except for the configured length limit. In production, provide exactly one reviewed, thread-safe redactor before enabling potentially sensitive capture. Multiple beans, redactor failures, or a `null` result fail closed and drop the affected content. Explicit core fluent calls to `.input(...)` and `.output(...)` remain intentional direct capture. +Enabling capture without a `ContentRedactor` exports the selected values unchanged up to the +configured limit. Production applications should install one reviewed, thread-safe redactor before +capturing sensitive content. See [SECURITY.md](SECURITY.md) for fail-closed behavior and streaming +limits. Explicit core `.input(...)` and `.output(...)` calls remain direct capture. -## 2. Exception message and stack trace are opt-in +## Exception details are opt-in -Automatic instrumentation and direct wrapper `.recordException(...)` calls retain the exception type but omit message and stack trace by default. To enable details for automatic instrumentation: +Automatic instrumentation and direct wrapper `.recordException(...)` calls keep the exception type +but omit message and stack trace by default: ```yaml langfuse: @@ -28,11 +34,10 @@ langfuse: max-length: 8192 ``` -With zero `ExceptionRedactor` beans, enabled details use identity behavior and are exported unchanged except for the configured length limit. In production, provide exactly one reviewed, thread-safe redactor before enabling potentially sensitive details. Multiple beans, redactor failures, or a `null` result fail closed. Code using the core API can configure `ExceptionCapturePolicy` and call `LangfuseOtel.recordException(...)`. +Use one reviewed `ExceptionRedactor` before enabling sensitive details. Code using the core API can +configure `ExceptionCapturePolicy` and call `LangfuseOtel.recordException(...)`. -## 3. Principal and HTTP session export are opt-in - -The starter no longer exports authenticated principal names or raw HTTP session IDs by default: +## Principal and session export are opt-in ```yaml langfuse: @@ -41,45 +46,50 @@ langfuse: capture-session-id: false ``` -Treat session IDs as credentials unless the application has explicitly defined a non-secret analytics identifier. - -## 4. Choose OpenTelemetry ownership deliberately +The user value comes from `Principal#getName()`. Treat HTTP session IDs as credentials unless the +application has defined them as non-secret analytics identifiers. -The default `langfuse.otel-mode=auto` reuses one unambiguous application `OpenTelemetry` bean. In that mode, `public-key`, `secret-key`, `host`, and `service-name` are ignored; the application SDK or Collector must already export OTLP traces to Langfuse. +## Choose OpenTelemetry ownership -Use one of these settings when auto-selection is not the intended behavior: +The default `langfuse.otel-mode=auto` reuses one unambiguous application `OpenTelemetry` bean. Its +export pipeline must already route traces to Langfuse; standalone keys and host settings are not +used in this mode. ```yaml langfuse: - otel-mode: external # require exactly one application OpenTelemetry bean - # otel-mode: standalone # create and own a dedicated Langfuse SDK/exporter + otel-mode: external # require one application OpenTelemetry bean + # otel-mode: standalone # own a dedicated Langfuse SDK and exporter ``` -Without Spring, `LangfuseOtel.externalBuilder(openTelemetry)` is non-owning. Its `flush()` and `close()` methods do not affect the supplied SDK. - -Standalone hosts now require HTTPS and reject user-info, query, fragment, missing-host, and non-HTTP(S) URIs. A plaintext receiver requires the explicit development-only builder option `.allowInsecureHttpForDevelopment(true)` or Spring property `langfuse.allow-insecure-http-for-development=true`, and its host must still be `localhost` or a literal loopback address. - -Legacy `LangfuseContext.set*()` and `clear()` mutations can override immutable metadata only within `LangfuseContext.makeCurrent(...)`, which restores them on close. A scope created directly from `LangfuseContext.storeIn(...)` remains immutable and ignores legacy mutations, preventing executor-thread state from escaping the scope. - -## 5. Streaming lifecycle is subscription-scoped - -Spring AI streaming creates a separate span and bounded output accumulator for every subscription. Merely creating a `Flux` no longer creates a span, and re-subscribing no longer reuses prior trace state. Verify any code that relied on eager instrumentation side effects. - -When output capture is enabled, a raw stream that exceeds `content.max-length` is now omitted entirely. Shorter streams are redacted at completion and then length-limited. This fail-closed behavior avoids exporting a pre-redaction prefix when a sensitive pattern crosses the buffer boundary. - -## 6. Validate adapter compatibility - -0.2 remains a production preview. Automatic instrumentation now uses a class-based proxy for safely proxyable models, preserving provider concrete types and extension interfaces. Final provider classes, final model methods, and externally callable final extension methods cannot be safely intercepted or delegated without changing behavior, so they remain unchanged and emit an instrumentation warning. Existing JDK proxies retain only the interfaces already present on that proxy. When singleton circular references are explicitly enabled, the BeanPostProcessor promotes one instrumented early proxy as the final singleton so injected early and final references retain identity. - -Streaming-only LangChain4j beans are no longer synchronous `ChatModel` candidates in the automatic BeanPostProcessor path. The legacy manual `TracingStreamingLangChain4jChatModel` keeps both interfaces for binary compatibility. `@ObserveGeneration` now tracks `CompletionStage` completion and Reactor terminal signals; future identity is preserved and Reactor spans are created per subscription. Reactor Scheduler work scheduled under the active instrumented subscription and final raw-thread downstream signals are restored automatically. When provider-side operators sit above a raw-thread source, wrap that source with `ReactorContextPropagation.wrap(...)`. For LangChain4j provider scheduling, use `LangChain4jStreamingContext.wrap(...)`/`taskWrapping(...)` at submission time or retain a per-invocation `Snapshot` for later submission. A completely opaque provider executor still requires provider configuration or agent instrumentation. - -An explicit `@ObserveGeneration` on any Spring AI or LangChain4j model method now takes precedence for the entire model bean. Automatic BeanPostProcessor instrumentation is skipped for that bean so the two mechanisms do not produce duplicate spans; annotate every model entry point that should remain traced. Keep model annotations and automatic model instrumentation as alternative bean-level strategies; annotations on ordinary service beans continue to work independently. +Without Spring, `LangfuseOtel.externalBuilder(openTelemetry)` is non-owning, so `flush()` and +`close()` do not affect the supplied SDK. + +## Operational and adapter changes + +- Applications with Actuator receive a `langfuse` health component and meters. Keep the component + out of liveness; include it in readiness only if trace-delivery failure should stop traffic. +- Spring AI streams create state per subscription. An unsubscribed `Flux` creates no span, and + each re-subscription creates a new one. +- Automatic model instrumentation preserves safely proxyable concrete types. Final or otherwise + non-proxyable model types remain unchanged and log a warning. +- A model-method `@ObserveGeneration` annotation takes precedence over automatic instrumentation + for that bean. +- Generic instrumentation cannot enter opaque provider-owned executors. Use + `ReactorContextPropagation` or `LangChain4jStreamingContext` at boundaries owned by the + integration. See [DESIGN.md](DESIGN.md#asynchronous-observation-and-context-lifecycle). +- Standalone hosts require HTTPS. The development-only plaintext option accepts loopback hosts + only. + +Legacy `LangfuseContext.set*()` and `clear()` mutations are scoped by +`LangfuseContext.makeCurrent(...)`. An immutable context installed directly with `storeIn(...)` +ignores those legacy mutations so they cannot escape through a reused executor thread. ## Upgrade checklist -- Run `./mvnw -B -ntp clean verify` and compile application consumers against `0.2.0-SNAPSHOT`. -- Decide `langfuse.otel-mode` and verify the selected exporter actually reaches Langfuse. -- Approve each content, exception, principal, and session field before enabling it. -- Exercise streaming complete, error, and cancellation paths under load. -- Confirm provider-specific concrete injection and extension APIs resolve through the proxied bean, and alert on any non-proxyable-model warning. -- Exercise annotated future and reactive methods through success, failure, cancellation, and re-subscription. +- Run `./mvnw -B -ntp clean verify` and compile application consumers against `0.2.0`. +- Choose `langfuse.otel-mode` and verify that the selected pipeline reaches Langfuse. +- Review content, exception, principal, and session capture before enabling each field. +- Exercise streaming completion, error, cancellation, and re-subscription. +- Confirm provider-specific concrete injection still resolves and investigate any non-proxyable + model warning. +- Add alerts for fail-safe fallback, export failures, queue drops, and flush failures. diff --git a/README.md b/README.md index 94268a9..6679765 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # langfuse-otel-java -**LLM observability for Java — zero config, one dependency.** +OpenTelemetry-based Langfuse tracing for Java, Spring AI, and LangChain4j. [![CI](https://github.com/ChoMinGi/langfuse-otel-java/actions/workflows/ci.yml/badge.svg)](https://github.com/ChoMinGi/langfuse-otel-java/actions) [![Java Core 11%2B / Starter 17%2B](https://img.shields.io/badge/Java-core%2011%2B%20%7C%20starter%2017%2B-blue)](https://openjdk.org/) @@ -10,66 +10,13 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![OpenTelemetry](https://img.shields.io/badge/OpenTelemetry-enabled-blueviolet)](https://opentelemetry.io/) -[Why this exists](#the-problem) · [Quick Start](#quick-start) · [What gets traced](#what-gets-traced) · [Features](#features) · [Compatibility](#compatibility) · [Migration](MIGRATION-0.2.md) · [Roadmap](ROADMAP.md) · [Release process](RELEASING.md) +[Quick Start](#quick-start) · [What gets traced](#what-gets-traced) · [Features](#features) · [Compatibility](#compatibility) · [Migration](MIGRATION-0.2.md) · [Roadmap](ROADMAP.md) ---- - -## The Problem - -[Langfuse](https://langfuse.com) is an open-source LLM observability platform — traces, costs, prompt management, and evaluations in one place. Python and TypeScript have first-class SDKs that make integration trivial. - -Java doesn't. - -If you're building LLM applications in Java with [Spring AI](https://spring.io/projects/spring-ai) or [LangChain4j](https://github.com/langchain4j/langchain4j), your options for Langfuse integration look like this: - -```java -// Raw OpenTelemetry — 40+ lines of boilerplate for every project -String authHeader = "Basic " + Base64.getEncoder().encodeToString((pk + ":" + sk).getBytes()); -OtlpHttpSpanExporter exporter = OtlpHttpSpanExporter.builder() - .setEndpoint(host + "/api/public/otel/v1/traces") - .addHeader("Authorization", authHeader) - .addHeader("x-langfuse-ingestion-version", "4").build(); -SdkTracerProvider provider = SdkTracerProvider.builder() - .setResource(Resource.builder().put("service.name", name).build()) - .addSpanProcessor(BatchSpanProcessor.builder(exporter).build()).build(); -// ... and 20 more lines for spans, attributes, gen_ai conventions, cleanup -``` - -This library eliminates all of it. - ---- - -## The Solution - -``` -┌─────────────────────────────────────────────────┐ -│ Your Application │ -│ │ -│ ┌──────────┐ ┌─────────────┐ ┌────────────┐ │ -│ │ Spring AI│ │ LangChain4j │ │ Direct API │ │ -│ └─────┬────┘ └──────┬──────┘ └─────┬──────┘ │ -│ │ │ │ │ -│ ▼ ▼ ▼ │ -│ ┌──────────────────────────────────────────┐ │ -│ │ langfuse-otel-java │ │ -│ │ │ │ -│ │ Chat · Streaming · Embeddings · Images │ │ -│ │ Auto-instrumented · Zero config │ │ -│ └─────────────────┬────────────────────────┘ │ -│ │ │ -└────────────────────┼────────────────────────────┘ - │ OTLP/HTTP - ▼ - ┌─────────────────┐ - │ Langfuse │ - │ Traces · Costs │ - │ Prompts · Evals │ - └─────────────────┘ -``` - -For a dedicated exporter, add one dependency, select standalone mode, and configure the connection properties. Supported Spring AI and LangChain4j calls — sync, streaming, embeddings, and image generation — are then exported to Langfuse. +The core module provides a small synchronous tracing API. The Spring Boot starter instruments +supported Spring AI and LangChain4j calls and can either reuse the application's OpenTelemetry SDK +or own a dedicated Langfuse exporter. --- @@ -133,6 +80,20 @@ try (LangfuseOtel langfuse = LangfuseOtel.builder() With the default `langfuse.otel-mode=auto`, the starter reuses exactly one application `OpenTelemetry` bean when available. It does not create an exporter and never flushes or shuts down the application-owned SDK. Configure that SDK or an OpenTelemetry Collector to export traces to Langfuse. If Langfuse keys are also configured, the starter warns that they are ignored in external mode. +For an OTLP/HTTP exporter configured through standard environment variables: + +```bash +export AUTH_STRING="$(printf '%s' "${LANGFUSE_PUBLIC_KEY}:${LANGFUSE_SECRET_KEY}" | base64 | tr -d '\n')" +export OTEL_EXPORTER_OTLP_ENDPOINT="https://cloud.langfuse.com/api/public/otel" +export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf" +export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic ${AUTH_STRING},x-langfuse-ingestion-version=4" +``` + +Configure equivalent endpoint and headers when building the SDK or Collector directly. The +ingestion-version header enables real-time v4 ingestion; without it, directly ingested traces can +be delayed. See Langfuse's [OpenTelemetry guide](https://langfuse.com/integrations/native/opentelemetry) +for other regions, self-hosting, and signal-specific settings. + Set `langfuse.otel-mode=external` to require one application bean, or `standalone` to force a dedicated Langfuse SDK/exporter. Ambiguous external bean configurations fail at startup instead of silently selecting the wrong telemetry pipeline. Without Spring, select the same non-owning mode explicitly: @@ -161,42 +122,11 @@ With the Spring Boot starter, the following models are **automatically instrumen | `EmbeddingModel` | `call(EmbeddingRequest)` | `embeddings` | | `ImageModel` | `call(ImagePrompt)` | `image_generation` | -```java -// Your existing code — completely unchanged -@Service -public class MyAiService { - private final ChatModel chatModel; - - // Sync — traced automatically - public String ask(String question) { - return chatModel.call(new Prompt(question)) - .getResult().getOutput().getText(); - } - - // Streaming — traced automatically, with time-to-first-token - public Flux askStream(String question) { - return chatModel.stream(new Prompt(question)) - .map(r -> r.getResult().getOutput().getText()); - } -} -``` - -Reactor scheduler transitions and downstream signals are bridged automatically. If a provider -owns a raw Reactive Streams source that emits from a plain thread, place the explicit boundary at -the closest raw source so provider-side operators also run with the subscription context: - -```java -Publisher rawPublisher = provider.openStream(prompt); - -return Flux.from(ReactorContextPropagation.wrap(rawPublisher)) - .map(this::providerSideMapping); -``` - -`ReactorContextPropagation.wrap(...)` resolves each subscription independently and scopes source -subscription, signals, request, and cancel. The automatic model/annotation wrapper still restores -the final downstream signal when this explicit boundary is absent, but it cannot reach provider -`map`/`filter` operators already assembled upstream of that final wrapper. Code executed by the raw -source before it calls a Reactive Streams boundary also remains provider-owned. +Reactor scheduler transitions and downstream signals are bridged automatically. Provider adapters +that own a raw, plain-thread source can place +`ReactorContextPropagation.wrap(rawPublisher)` at that source boundary. See the +[async lifecycle design](DESIGN.md#asynchronous-observation-and-context-lifecycle) for the exact +boundary. ### LangChain4j @@ -207,43 +137,12 @@ source before it calls a Reactive Streams boundary also remains provider-owned. | `EmbeddingModel` | `embedAll(...)`, `embed(...)` | `embeddings` | | `ImageModel` | `generate(...)` | `image_generation` | -```java -// Sync — traced automatically -chatModel.chat(ChatRequest.builder() - .messages(UserMessage.from("Hello")).build()); - -// Streaming — traced automatically -streamingModel.chat("Hello", new StreamingChatResponseHandler() { - @Override public void onPartialResponse(String token) { /* ... */ } - @Override public void onCompleteResponse(ChatResponse response) { /* ... */ } - @Override public void onError(Throwable error) { /* ... */ } -}); -``` - -Streaming callbacks and model listeners run with the wrapper observation and immutable Langfuse request metadata restored automatically. When a provider accepts an `Executor`, `ExecutorService`, or `ScheduledExecutorService`, configure it with the matching submission-time adapter: - -```java -executor.execute(LangChain4jStreamingContext.wrap(() -> providerCall(handler))); -Executor contextAware = LangChain4jStreamingContext.taskWrapping(executor); -ScheduledExecutorService contextAwareScheduler = - LangChain4jStreamingContext.taskWrapping(scheduler); -``` - -These adapters capture the context at submission, so they cover work submitted from `doChat` or a restored callback/listener. If a provider retains a request and submits it later from an unscoped control thread, capture one fixed snapshot while `doChat` is active and retain it with that request: - -```java -LangChain4jStreamingContext.Snapshot invocation = - LangChain4jStreamingContext.capture(); - -// This may be called after doChat has returned and from another thread. -ScheduledExecutorService invocationScheduler = - invocation.taskWrapping(scheduler); -invocationScheduler.schedule(() -> providerCall(handler), 10, TimeUnit.MILLISECONDS); -``` - -A snapshot is thread-safe and opens only short-lived scopes while a task executes. It is also terminal-aware: a task whose wrapper execution is admitted after terminal cleanup still runs, but without restoring the ended observation. A task admitted before cleanup is already in flight and finishes with its captured context; terminal completion does not block on arbitrary provider work. Keep one snapshot per invocation and release it with the provider request state after the terminal callback; the library keeps no global request registry. - -A provider-owned executor that exposes neither configuration nor a scheduling hook cannot be intercepted through the generic LangChain4j SPI. Such a provider requires its own executor configuration/adapter or OpenTelemetry agent instrumentation; callbacks remain covered automatically. +Streaming callbacks and model listeners restore the wrapper observation and immutable request +metadata. Provider adapters that control task submission can use +`LangChain4jStreamingContext.wrap(...)`, `taskWrapping(...)`, or a per-request `Snapshot`. +Opaque provider executors remain outside the generic SPI; the +[async lifecycle design](DESIGN.md#asynchronous-observation-and-context-lifecycle) describes that +limit. ### Auto-captured attributes @@ -275,30 +174,13 @@ public class LLMService { } ``` -`@ObserveGeneration` tracks synchronous methods, the actual completion of `CompletionStage` values, and Reactor `Mono`/`Flux` terminal signals. A `CompletionStage` is returned unchanged, including its concrete type and identity. Reactor observations are created per subscription, so an unsubscribed publisher creates no span and re-subscription creates an independent span. Tasks scheduled through Reactor while the instrumented subscription context is current inherit the observation and request metadata until that subscription terminates. When output capture is enabled for a multi-value publisher, the last emitted value is used as the automatic output. - -Explicit annotation wins at the model-bean boundary: if a Spring AI or LangChain4j model bean has `@ObserveGeneration` on any model method, BeanPostProcessor-based model instrumentation is skipped for that whole bean to prevent duplicate generation spans. Use one instrumentation style per model bean and annotate every model entry point that needs tracing; annotations on ordinary service methods are unaffected. - -When circular references are explicitly enabled, the model post-processors participate in Spring's early-reference phase so injected collaborators and the final singleton receive the same instrumented proxy. Spring Boot still disables circular references by default. +`@ObserveGeneration` covers synchronous methods, `CompletionStage`, and Reactor `Mono`/`Flux`. +Stages retain their identity, and Reactor creates one observation per subscription. On a model +bean, explicit annotations take precedence over automatic instrumentation so the same call is not +traced twice. ### Request Context Propagation -```java -// Set once in a filter or interceptor -LangfuseContext.setUserId("user-123"); -LangfuseContext.setSessionId("session-456"); -LangfuseContext.setTags("prod", "v2"); - -// Synchronous traces on this thread inherit these values -langfuse.trace("flow", trace -> { ... }); - -// Spring Boot filters can extract HTTP metadata after explicit opt-in -// Servlet: Principal → userId, HttpSession → sessionId -// WebFlux: stores the opted-in immutable metadata in Reactor Context -``` - -New integrations can use immutable metadata with OTel Context directly: - ```java LangfuseTraceContext metadata = LangfuseTraceContext.builder() .userId("user-123") @@ -311,7 +193,8 @@ try (Scope ignored = LangfuseContext.makeCurrent(metadata)) { } ``` -`LangfuseContext.makeCurrent(...)` is also the restoration boundary for legacy `set*()` and `clear()` calls made inside that scope. A context installed directly with `storeIn(...).makeCurrent()` remains immutable, so legacy mutations inside such an unmanaged scope are ignored instead of leaking into a reused executor thread. +Spring MVC and WebFlux filters can also extract Principal and HTTP session metadata after explicit +opt-in. The legacy `LangfuseContext.set*()` methods remain available for synchronous code. ### Content Capture and Redaction @@ -334,11 +217,9 @@ ContentRedactor contentRedactor() { } ``` -Capture opt-in does not implicitly install a redactor. With zero `ContentRedactor` beans, the identity redactor is used and enabled content is exported unchanged except for the length limit. Production environments that may capture sensitive values should treat exactly one thread-safe redactor as required. Multiple beans fail closed and drop automatic content; redactor failures or a `null` result also drop the affected value. - -Non-streaming values are redacted before the length limit is applied. Spring AI streaming retains only a bounded raw value; if the raw stream exceeds `max-length` before terminal redaction, the output attribute is dropped entirely instead of exporting a prefix that could bypass a boundary-sensitive redactor. - -The policy applies to automatic Spring AI, LangChain4j, streaming, embedding, image, and `@ObserveGeneration` capture. Explicit calls to the core fluent `.input()` and `.output()` methods remain an intentional opt-in by the caller. +No redactor is installed automatically. If capture is enabled without one, values are exported +unchanged except for the length limit. Use one reviewed, thread-safe redactor in production; +ambiguous or failing redactors drop the affected automatic content. Exception details use a separate safe-by-default policy. Only the exception type is recorded unless message or stack capture is enabled: @@ -350,15 +231,30 @@ langfuse: max-length: 8192 ``` -Exception detail opt-in also uses an identity redactor when no `ExceptionRedactor` bean exists. In production, provide exactly one thread-safe redactor before enabling potentially sensitive messages or stacks. Multiple beans, redactor failures, or a `null` result fail closed and drop the affected details. Automatic wrappers and `LangfuseOtel.recordException(...)` use this policy; the direct span wrapper `.recordException(...)` remains type-only. - -Stack capture renders exception types and frames without embedding throwable messages, including messages from causes and suppressed exceptions. Enable `capture-message` separately when the redacted message is required. +Exception detail capture follows the same rule with `ExceptionRedactor`. See +[SECURITY.md](SECURITY.md) for the full data boundary and fail-closed behavior. ### Prompt Management Integrates with [langfuse-java](https://github.com/langfuse/langfuse-java) for prompt versioning: +```xml + + com.langfuse + langfuse-java + 0.2.0 + +``` + +The prompt client is optional and is not pulled in transitively. Create it with the same Langfuse +credentials and host before using the helper: + ```java +LangfuseClient langfuseClient = LangfuseClient.builder() + .credentials("pk-lf-...", "sk-lf-...") + .url("https://cloud.langfuse.com") + .build(); + trace.generation("llm", gen -> { String compiled = gen.prompt(langfuseClient, "my-prompt") .variable("domain", "HR") @@ -369,27 +265,31 @@ trace.generation("llm", gen -> { }); ``` -### 3 Tracing Styles +### Core tracing API -```java -// Callback (recommended) -langfuse.trace("flow", trace -> { - trace.generation("llm", gen -> { gen.model("gpt-4o").output(callLLM()); }); -}); +The core API supports callbacks, try-with-resources, and explicit `end()`. All three are +synchronous scope APIs: close a handle on the thread that created it and do not pass it into an +asynchronous callback. -// Try-with-resources -try (var trace = langfuse.trace("flow")) { - try (var gen = trace.generation("llm")) { gen.model("gpt-4o").output(callLLM()); } -} +### Operational Signals -// Manual end() -var gen = trace.generation("llm").model("gpt-4o"); -gen.output(result).end(); -``` +With the default fail-safe setting, missing API keys or an invalid standalone configuration fall +back to no-op mode without crashing the application. `LangfuseOtel.getStatus()` returns an +immutable snapshot of that fallback, ownership, export, queue-drop, and flush state. Reading it +does not flush or make a network request. + +When the application includes `spring-boot-starter-actuator`, the starter registers a `langfuse` health component and Micrometer meters. The Actuator dependency remains optional and is not added transitively by this library. -### Fail-safe by Default +- `UP`: the standalone pipeline is owned and has no current failure. This can include the initial state before the first export. +- `DOWN`: the latest export failed, a queue drop has not yet been followed by a successful export, or the latest flush failed or timed out. +- `OUT_OF_SERVICE`: fail-safe construction produced the library's no-op fallback. +- `UNKNOWN`: OpenTelemetry is application-owned, so its exporter, queue, and flush state are not observed here. -Missing API keys or a misconfigured endpoint? With the default fail-safe builder, the library logs a warning and switches to no-op mode. Your application does not crash because of observability; production deployments should still alert on that warning until a health indicator is available. +Owned pipelines publish fallback, export-failure, queue-drop, and flush meters. External mode omits +transport meters because this library does not own that pipeline. A successful flush confirms a +local SDK drain, not remote ingestion. + +Keep this component out of liveness. Add it to readiness only when losing Langfuse delivery should intentionally stop the application from receiving traffic. --- @@ -424,13 +324,12 @@ Missing API keys or a misconfigured endpoint? With the default fail-safe builder ## Production-preview limitations -- WebFlux request metadata and the wrapper OpenTelemetry context propagate through Spring AI streams and `@ObserveGeneration` Reactor publishers, including raw downstream signals and Reactor Scheduler tasks scheduled while that instrumented subscription context is current. The keyed hook is removed after the last subscription lease closes, and a task whose execution starts after termination does not restore the ended context. Provider-side operators above a raw-thread source require `ReactorContextPropagation.wrap(rawPublisher)` at that source; arbitrary work performed before the source invokes `subscribe`, a signal, `request`, or `cancel` cannot be intercepted by a Reactive Streams adapter. -- LangChain4j streaming callbacks and model listeners restore the invocation context automatically. Provider-internal work submitted to an owned raw executor must use `LangChain4jStreamingContext.wrap(...)`, `taskWrapping(...)`, a per-invocation `Snapshot` for late submission, provider-specific configuration, or agent instrumentation; an opaque executor cannot be reached through the generic model SPI. -- The 0.2 compatibility matrix is JVM-only. The reflective LangChain4j 1.18 cancellation bridge is not validated for Spring AOT or GraalVM native images and fails open when its runtime types cannot be reflected; native-image support requires dedicated runtime hints and tests before it can be claimed. -- Automatic model instrumentation uses a class-based proxy for safely proxyable provider classes, preserving their concrete type and extension interfaces. Final classes, final model methods, or externally callable final extension methods are left unchanged and logged as uninstrumented rather than replaced with an incompatible interface decorator. -- Existing JDK proxies retain their declared interfaces; they cannot recover a concrete type that was already removed by the original proxy. -- Completion-aware annotation support covers `CompletionStage` and declared return types compatible with Reactor `Mono`, `Flux`, or `Publisher`. A custom concrete publisher subtype is returned unchanged and is not automatically tracked when a compatible wrapper type cannot be preserved. -- The legacy manual `TracingStreamingLangChain4jChatModel` keeps both synchronous and streaming interfaces for binary compatibility; the automatic BeanPostProcessor path preserves a streaming-only bean's original interface set. +- Generic instrumentation cannot enter opaque provider-owned executors. Integrations that own a + raw source or scheduling boundary must use the supplied context adapters. +- Final or otherwise non-proxyable model types are left unchanged and logged as uninstrumented. +- `0.2.x` is JVM-only; Spring AOT and GraalVM native-image support are not claimed. +- Custom concrete publisher subtypes are returned unchanged when a compatible wrapper type cannot + be preserved. Remaining production work is tracked in [ROADMAP.md](ROADMAP.md); `0.2.0-SNAPSHOT` is not the final production release. @@ -440,15 +339,18 @@ Remaining production work is tracked in [ROADMAP.md](ROADMAP.md); `0.2.0-SNAPSHO |-----------|---------------|-------| | Java | 11+ | Core module | | Java | 17+ | Spring Boot starter | -| OpenTelemetry SDK | 1.44.1 | Via BOM | -| Spring Boot | 3.4.x | Auto-configuration | -| Spring AI | 1.0.0 — 1.1.8 | Chat, streaming, embeddings, images | -| Spring AI | 2.0.0 | Current stable (CI tested) | -| LangChain4j | 1.0.0 — 1.18.0 | Chat, streaming, embeddings, images | -| langfuse-java | 0.2.x | Prompt management (optional) | +| OpenTelemetry SDK | 1.62.0 | Via BOM | +| Spring Boot | 3.5.16 | Boot 3 auto-configuration | +| Spring AI | 1.0.9 / 1.1.8 | Chat, streaming, embeddings, images | +| LangChain4j | 1.0.0 / 1.18.0 | Chat, streaming, embeddings, images; provider internals vary | +| langfuse-java | 0.2.0 | Prompt management (optional) | | Langfuse Cloud | v3+ | OTLP ingestion | | Langfuse Self-hosted | v3.22.0+ | Requires OTLP support | +`0.2.x` is the Spring Boot 3 and Spring AI 1 line. `0.3.x` will move the same starter +coordinates to Spring Boot 4 and Spring AI 2; the core module remains framework-neutral. See +[SECURITY.md](SECURITY.md#supported-versions) for the maintenance window. + ## Examples See the [examples](./examples) directory: diff --git a/RELEASING.md b/RELEASING.md index 7c67f87..b2cc99f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -7,31 +7,39 @@ This project deliberately separates release-candidate validation from public pub 1. Create a protected GitHub environment named `central-validation`. 2. Add required reviewers and restrict deployment branches/tags to the release policy. 3. Prefer environment-scoped `CENTRAL_USERNAME`, `CENTRAL_TOKEN`, `GPG_PRIVATE_KEY`, and `GPG_PASSPHRASE` secrets. -4. Protect `main` and release tags. Require the normal `verify`, supported Java/framework matrix, and consumer checks on `main`. +4. Protect `main` and release tags. Require the quality, `verify`, supported Java/framework + matrix, and consumer checks on `main`. 5. Set the repository variable `LANGFUSE_INTEGRATION_ENABLED=true` only when maintained live-export credentials are available. When enabled, incomplete credentials fail CI; when disabled, CI records that the export smoke was not run. This smoke exercises export and flush but does not perform Langfuse ingestion read-back. GitHub creates an unprotected environment automatically when a workflow first references an unknown name. Confirm the protection rules before pushing a release tag. ## Prepare the release commit -Use `0.2.0` below as an example and make all changes in one commit on `main`: +Use `0.2.0` below as an example. Prepare these changes as one commit on a release branch, then +merge it through the normal `main` protections: 1. Change the root and both module parent versions from `0.2.0-SNAPSHOT` to `0.2.0`. -2. Change `langfuse-otel.version` in both example POMs to `0.2.0`. +2. Change `langfuse-otel.version` in both example POMs and + `consumer-tests/core-prompt-consumer/pom.xml` to `0.2.0`. 3. Replace the README snapshot notice and both dependency snippets with `0.2.0`. 4. Move the changelog content to a dated `## [0.2.0] - YYYY-MM-DD` heading. Keep a separate empty `## [Unreleased]` section for future work. 5. Set `project.build.outputTimestamp` once to a stable UTC timestamp for the release commit. Do not derive it from the current build time. 6. Review `SECURITY.md`, compatibility claims, and migration notes for the release version. -Run the same offline gates locally: +Run the Maven gates locally: ```bash ./mvnw -B -ntp clean verify -./mvnw -B -ntp -DskipTests install +./mvnw -B -ntp -Pquality -DskipTests -Djacoco.skip=true -Dmaven.javadoc.skip=true clean verify +./mvnw -B -ntp -DskipTests -Djacoco.skip=true install ./mvnw -B -ntp -f examples/spring-ai-example/pom.xml -DskipTests verify ./mvnw -B -ntp -f examples/langchain4j-example/pom.xml -DskipTests verify +./mvnw -B -ntp -f consumer-tests/core-prompt-consumer/pom.xml verify ``` +The release workflow also scans the generated SBOM and rejects High or Critical vulnerability +findings. + Merge the release commit into `main` and wait for required main CI checks. Create a signed annotated tag only after that commit is present on `main`; the signing key must be registered with GitHub so the tag signature is reported as verified: ```bash @@ -46,7 +54,10 @@ Before Central credentials are available, the workflow verifies: - an annotated release tag with a GitHub-verified signature, root/module/example versions, release commit, and `main` ancestry; - release-version snippets in README and a dated changelog heading; - absence of an existing GitHub release or published Maven Central coordinates; -- `clean verify`, core Java 11/17/21, the full blocking Spring AI/LangChain4j matrix, and both consumer builds. +- `clean verify`, including coverage, warning-free Javadocs, and binary/source compatibility with + `0.1.1`; +- SpotBugs, dependency-license, CycloneDX SBOM, and High/Critical vulnerability gates; +- core Java 11/17/21, the blocking Spring AI/LangChain4j matrix, and all consumer checks. The `central-validation` environment is entered only after every gate succeeds. The deploy job builds and signs once, uploads a candidate named `io.github.chomingi:langfuse-otel-java:`, waits for `VALIDATED`, and stops. The following job has the only `contents:write` permission and creates a draft GitHub release without checking out or building repository code. @@ -54,7 +65,8 @@ The `central-validation` environment is entered only after every gate succeeds. 1. Open [Central Publisher Portal deployments](https://central.sonatype.com/publishing/deployments). 2. Locate `io.github.chomingi:langfuse-otel-java:` and record its deployment ID in the release issue or audit record. -3. Require state `VALIDATED`. Inspect the listed POM, JAR, sources, Javadoc, checksums, and signatures. Drop the candidate instead of publishing if any coordinate or metadata is wrong. +3. Require state `VALIDATED`. Inspect the listed POM, JAR, sources, Javadoc, SBOM, checksums, and + signatures. Drop the candidate instead of publishing if any coordinate or metadata is wrong. 4. Optionally resolve the validated candidate through Central's manual-testing repository as described in the [Publisher API documentation](https://central.sonatype.org/publish/publish-portal-api/#manually-testing-a-deployment-bundle). 5. Select **Publish** in the Portal. Wait until the deployment state is `PUBLISHED`. @@ -81,7 +93,9 @@ gh release view v0.2.0 --json isDraft,url gh release edit v0.2.0 --draft=false --latest ``` -Finally, move `main` to the next development version, update both example properties and README consistently, and restore the changelog's `Unreleased` section. +Finally, move `main` to the next development version, update both example properties and README +consistently, restore the changelog's `Unreleased` section, and advance +`api.compatibility.baseline` to the released version. ## Failure and retry policy diff --git a/ROADMAP.md b/ROADMAP.md index de5375f..9606f5a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,82 +1,35 @@ -# Production Roadmap +# Roadmap -## 0.2.0 Production Preview +This file tracks release-level work. Completed implementation details live in +[CHANGELOG.md](CHANGELOG.md), and the publication procedure lives in +[RELEASING.md](RELEASING.md). -### Implemented in the current development line +## 0.2.0 — Spring Boot 3 production preview -- [x] Preserve the previous local history and base 0.2 work on the latest remote main -- [x] Align reactor versions at `0.2.0-SNAPSHOT` -- [x] Add a checksum-pinned Maven Wrapper -- [x] Produce bit-for-bit stable main, sources, and Javadoc JARs with a fixed build timestamp -- [x] Make `clean verify` (tests, package, sources, Javadoc) a required CI job -- [x] Validate release tag, POM version, and main ancestry -- [x] Stop Maven Central at validated state for manual publish approval -- [x] Support application-owned `OpenTelemetry` without duplicate SDK/exporter ownership -- [x] Add explicit `auto`, `external`, and `standalone` OTel selection with ambiguity checks -- [x] Default automatic content capture to metadata-only -- [x] Add per-direction opt-in, redaction callback, and post-redaction length limit -- [x] Default exception capture to type-only with separate redacted message/stack opt-ins -- [x] Keep stack-only capture free of throwable messages, including causes and suppressed exceptions -- [x] Make HTTP Principal and session ID export explicit opt-ins -- [x] Propagate immutable request metadata through OTel/Reactor Context -- [x] Isolate concurrent WebFlux request context across scheduler changes -- [x] Create Spring AI streaming state per subscription -- [x] Cover no-subscribe, re-subscribe, concurrent subscribe, error, cancel, and context cases -- [x] Bound enabled streaming-output accumulators and avoid allocating them in metadata-only mode -- [x] Preserve provider embedding-dimension and image-edit methods -- [x] Preserve proxyable provider concrete types and extension interfaces during automatic instrumentation -- [x] Keep final/non-proxyable model beans unchanged instead of substituting an incompatible decorator -- [x] Keep streaming-only LangChain4j beans out of synchronous `ChatModel` candidates in automatic BeanPostProcessor instrumentation -- [x] Make `@ObserveGeneration` completion-aware for Reactor and `CompletionStage` results -- [x] Give explicit model-method annotations bean-level precedence to prevent duplicate observations -- [x] Bridge the Spring AI source-subscription boundary into the wrapper observation context -- [x] Make synchronous provider spans children of raw wrapper observations -- [x] Promote one instrumented early model proxy across explicitly enabled singleton circular references -- [x] Carry wrapper observation context and immutable metadata across Reactor Scheduler hops with a per-subscription, terminal-bound lease -- [x] Restore raw Reactive Streams signals, request, and cancel, and expose a per-subscription source-boundary bridge for provider-side operators -- [x] Restore LangChain4j streaming callbacks with the captured invocation context and expose terminal-aware submission-time and fixed-snapshot executor bridges for provider-owned scheduling -- [x] Restore OTel Scope even when a raw span is ended before its wrapper -- [x] Compile consumer examples against the locally built snapshot in CI -- [x] Gate Central credentials behind release preflight, Java/framework matrices, and consumer builds -- [x] Add a manual Central publish and post-publication resolution runbook -- [x] Publish a 0.1.x to 0.2 migration guide for privacy and OTel ownership defaults +The code and build now cover the release scope: -### Required before the 0.2.0 release +- explicit application-owned or standalone OpenTelemetry, with safe transport and privacy defaults +- isolated Spring AI/Reactor and LangChain4j streaming lifecycles +- type-preserving model instrumentation and completion-aware `@ObserveGeneration` +- local status plus optional Actuator health and metrics +- reproducible artifacts and the release gates documented in [RELEASING.md](RELEASING.md) -- [x] Add a mock OTLP receiver contract test for endpoint path, span payload/name, and auth headers -- [ ] Extend the mock OTLP receiver contract to assert multi-span hierarchy and representative attributes -- [x] Reject insecure standalone endpoints unless an explicit development-only opt-in is set -- [x] Add redirect/credential-host safety tests for the standalone exporter -- [x] Add an explicit policy for exception messages and user metadata that may contain sensitive values -- [x] Bound pre-redaction streaming buffers and drop overflowing raw streams before redaction -- [x] Restore immutable request metadata at Spring AI/annotated Reactor subscription and LangChain4j callback boundaries -- [x] Preserve concrete/provider extension types or leave non-proxyable beans unchanged instead of substituting their model type -- [x] Stop exposing streaming-only LangChain4j models as synchronous `ChatModel` candidates in automatic BeanPostProcessor instrumentation -- [x] Make `@ObserveGeneration` completion-aware for Reactor and `CompletionStage` results -- [x] Propagate wrapper observation context into Reactor Scheduler tasks scheduled under the active instrumented subscription -- [x] Restore wrapper observation context around LangChain4j streaming callbacks and expose `LangChain4jStreamingContext` submission-time and late-scheduling snapshot bridges -- [ ] Validate and document supported-provider executor configuration where LangChain4j implementations use an opaque internal executor; generic SPI coverage stops at the callback and explicit scheduling boundaries -- [ ] Expose Actuator health/metrics for ownership mode, no-op fallback, exporter failures, queue drops, and flush state -- [ ] Add JaCoCo baseline and prevent coverage regression -- [ ] Add static analysis, dependency vulnerability, license, and SBOM gates -- [ ] Add binary/source API compatibility checks against 0.1.1 -- [ ] Resolve public API Javadoc warnings -- [ ] Run the Java/framework compatibility matrix on the release candidate -- [ ] Rehearse Central validation, manual publish, draft release publication, and post-publish resolution +Two checks must run against the exact release commit: -### Required before 1.0 RC +- [ ] Run the Java and framework compatibility matrix. +- [ ] Complete Central validation and manual publication, verify public dependency resolution, then + publish the generated GitHub draft. -- [ ] Verify trace/generation hierarchy and attributes through the real Langfuse API -- [ ] Run concurrency, exporter-outage, queue-saturation, shutdown, performance, and soak tests -- [ ] Record zero span/context leaks under sustained reactive and streaming load -- [x] Validate custom Publisher integrations that dispatch outside Reactor Scheduler hooks with `ReactorContextPropagation.wrap(...)`, including concurrent subscriptions, terminal races, and lease cleanup -- [ ] Add Spring AOT/GraalVM runtime hints and a native-image matrix before claiming native support for reflective adapter paths -- [ ] Freeze the supported Java, Spring Boot, Spring AI, and LangChain4j matrix -- [ ] Complete a threat model and external security review +## 0.3.0 — Spring Boot 4 adapter line -## 0.3 Adapter Direction +- Keep the current Maven coordinates and move the starter to Spring Boot 4 and Spring AI 2. +- Keep one framework generation per starter release; the core module remains framework-neutral. +- Prefer Spring AI observation hooks and LangChain4j listener/context hooks where they provide equivalent coverage. -- Replace Spring AI model BeanPostProcessor wrappers with its Observation extension points where supported -- Prefer framework-native LangChain4j listener/context hooks over model proxying where supported, retaining an explicit provider scheduling boundary -- Centralize and version the GenAI semantic attribute mapping -- Split core API, framework adapters, and optional standalone SDK/autoconfigure responsibilities +## Before 1.0 RC + +- Validate trace hierarchy and attributes through the real Langfuse API. +- Run outage, queue-saturation, shutdown, performance, and soak tests, including leak checks. +- Add Spring AOT/GraalVM runtime hints and a native-image matrix before claiming native support. +- Freeze the supported Java and framework matrix. +- Complete a threat model and external security review. diff --git a/SECURITY.md b/SECURITY.md index 927464e..430c07e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,10 +5,10 @@ If you discover a security vulnerability, please report it responsibly: 1. **Do NOT open a public issue.** -2. Use [GitHub's private vulnerability reporting](https://github.com/ChoMinGi/langfuse-otel-java/security/advisories/new) or email the maintainer directly. +2. Use [GitHub's private vulnerability reporting](https://github.com/ChoMinGi/langfuse-otel-java/security/advisories/new). 3. Include a description of the vulnerability, steps to reproduce, and potential impact. -We will respond within 48 hours and work to release a fix promptly. +We aim to acknowledge reports within 72 hours and provide an initial severity assessment within seven days. These are best-effort targets, not a service-level agreement; the remediation timeline depends on impact and release risk. ## Scope @@ -22,8 +22,8 @@ This library transmits LLM trace data to Langfuse via OTLP/HTTP. The following d | Principal name | Only when HTTP context user capture is explicitly enabled | Keep `langfuse.context.capture-user-id=false` (default) | | HTTP session ID | Only when HTTP context session capture is explicitly enabled | Keep `langfuse.context.capture-session-id=false` (default) | | Exception message and stack trace | Only when each exception detail is explicitly enabled | Keep `langfuse.exception.capture-message/stack-trace=false` (default) | -| Token counts | All model calls | Always sent if available (not sensitive) | -| Model parameters | All model calls | Always sent if available (not sensitive) | +| Token counts | All model calls | Sent when available | +| Model parameters | All model calls | Sent when available | Automatic content capture is metadata-only by default. Enabling it does not require or install an application redactor: with zero `ContentRedactor` beans, the identity redactor exports enabled content unchanged except for truncation to `langfuse.content.max-length` (default `8192`). Exactly one bean processes content before truncation. Multiple beans fail closed and drop automatic content; a redactor failure or `null` result also drops the affected value. Treat one reviewed, thread-safe redactor as mandatory in production whenever captured content may be sensitive. @@ -53,5 +53,7 @@ The standalone transport contract test also verifies that cross-origin redirects | Version | Supported | |---------|-----------| -| 0.2.x | Production preview | -| 0.1.x | Security fixes | +| 0.2.x | Production preview; maintained from 0.2.0 | +| 0.1.x | Security fixes until 0.2.0 is released | + +When `0.3.0` moves the starter to Spring Boot 4 and Spring AI 2, `0.2.x` will remain in maintenance for six months. That window covers critical vulnerabilities and regressions owned by this library; it does not extend the support lifecycle of Spring Boot 3 or other upstream dependencies. diff --git a/config/spotbugs-exclude.xml b/config/spotbugs-exclude.xml new file mode 100644 index 0000000..0aadf39 --- /dev/null +++ b/config/spotbugs-exclude.xml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/consumer-tests/core-prompt-consumer/pom.xml b/consumer-tests/core-prompt-consumer/pom.xml new file mode 100644 index 0000000..9944004 --- /dev/null +++ b/consumer-tests/core-prompt-consumer/pom.xml @@ -0,0 +1,75 @@ + + 4.0.0 + + com.example + core-prompt-consumer + 1.0-SNAPSHOT + + + UTF-8 + 11 + 0.2.0-SNAPSHOT + 5.9.3 + + + + + io.github.chomingi + langfuse-otel-core + ${langfuse-otel.version} + + + com.langfuse + langfuse-java + 0.2.0 + + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.6.3 + + + enforce-dependency-convergence + + enforce + + + + + + + + + + + + diff --git a/consumer-tests/core-prompt-consumer/src/test/java/com/example/CorePromptClasspathTest.java b/consumer-tests/core-prompt-consumer/src/test/java/com/example/CorePromptClasspathTest.java new file mode 100644 index 0000000..323bbe3 --- /dev/null +++ b/consumer-tests/core-prompt-consumer/src/test/java/com/example/CorePromptClasspathTest.java @@ -0,0 +1,31 @@ +package com.example; + +import com.langfuse.client.LangfuseClient; +import io.github.chomingi.langfuse.otel.LangfuseOtel; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; + +class CorePromptClasspathTest { + + @Test + void initializesExporterAndPromptClientOnTheSameConsumerClasspath() { + LangfuseClient promptClient = LangfuseClient.builder() + .credentials("pk-test", "sk-test") + .url("http://127.0.0.1:1") + .build(); + + try (LangfuseOtel langfuse = LangfuseOtel.builder() + .publicKey("pk-test") + .secretKey("sk-test") + .host("http://127.0.0.1:1") + .allowInsecureHttpForDevelopment(true) + .langfuseClient(promptClient) + .failSafe(false) + .build()) { + assertFalse(langfuse.isNoop()); + assertSame(promptClient, langfuse.getLangfuseClient()); + } + } +} diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..8abb091 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,22 @@ +# Examples + +These are standalone consumer projects rather than modules in the main Maven build. Install the +current snapshot from the repository root, then provide test credentials: + +```bash +./mvnw -B -ntp -DskipTests -Djacoco.skip=true install + +export OPENAI_API_KEY="..." +export LANGFUSE_PUBLIC_KEY="pk-lf-..." +export LANGFUSE_SECRET_KEY="sk-lf-..." +# export LANGFUSE_HOST="https://cloud.langfuse.com" +``` + +Run either example from the repository root: + +```bash +./mvnw -f examples/spring-ai-example/pom.xml spring-boot:run +./mvnw -f examples/langchain4j-example/pom.xml spring-boot:run +``` + +Each process makes live OpenAI calls and sends traces to the configured Langfuse project. diff --git a/examples/langchain4j-example/pom.xml b/examples/langchain4j-example/pom.xml index f75b957..159021f 100644 --- a/examples/langchain4j-example/pom.xml +++ b/examples/langchain4j-example/pom.xml @@ -6,7 +6,7 @@ org.springframework.boot spring-boot-starter-parent - 3.4.0 + 3.5.16 com.example @@ -15,6 +15,7 @@ 17 + 2.21.5 0.2.0-SNAPSHOT @@ -32,12 +33,30 @@ dev.langchain4j langchain4j-open-ai - 1.0.0 + 1.18.0 + + org.apache.maven.plugins + maven-enforcer-plugin + 3.6.3 + + + enforce-dependency-convergence + + enforce + + + + + + + + + org.springframework.boot spring-boot-maven-plugin diff --git a/examples/spring-ai-example/pom.xml b/examples/spring-ai-example/pom.xml index 9d91f70..7be8eb0 100644 --- a/examples/spring-ai-example/pom.xml +++ b/examples/spring-ai-example/pom.xml @@ -6,7 +6,7 @@ org.springframework.boot spring-boot-starter-parent - 3.4.0 + 3.5.16 com.example @@ -15,6 +15,7 @@ 17 + 2.21.5 0.2.0-SNAPSHOT @@ -32,7 +33,7 @@ org.springframework.ai spring-ai-starter-model-openai - 1.0.0 + 1.0.9 @@ -44,6 +45,24 @@ + + org.apache.maven.plugins + maven-enforcer-plugin + 3.6.3 + + + enforce-dependency-convergence + + enforce + + + + + + + + + org.springframework.boot spring-boot-maven-plugin diff --git a/langfuse-otel-core/pom.xml b/langfuse-otel-core/pom.xml index fe5bcda..ea0cde3 100644 --- a/langfuse-otel-core/pom.xml +++ b/langfuse-otel-core/pom.xml @@ -13,6 +13,11 @@ langfuse-otel-core Core library for OpenTelemetry integration with Langfuse + + 0.873 + 0.762 + + @@ -38,6 +43,17 @@ io.opentelemetry opentelemetry-exporter-otlp + + + io.opentelemetry + opentelemetry-exporter-sender-okhttp + + + + + io.opentelemetry + opentelemetry-exporter-sender-jdk + runtime org.slf4j @@ -67,5 +83,11 @@ opentelemetry-sdk-testing test + + io.opentelemetry.proto + opentelemetry-proto + ${otel.proto.version} + test + diff --git a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/AbstractLangfuseSpan.java b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/AbstractLangfuseSpan.java index d54c849..21e70c1 100644 --- a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/AbstractLangfuseSpan.java +++ b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/AbstractLangfuseSpan.java @@ -1,27 +1,34 @@ package io.github.chomingi.langfuse.otel; import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Context; import io.opentelemetry.context.Scope; import java.util.concurrent.atomic.AtomicBoolean; abstract class AbstractLangfuseSpan implements AutoCloseable { + /** Underlying OpenTelemetry span. */ protected final Span span; private final Scope scope; + private final Thread openingThread; + private final Context installedContext; private final java.lang.ref.Cleaner.Cleanable cleanable; private final AtomicBoolean closed = new AtomicBoolean(false); protected AbstractLangfuseSpan(Span span, String name) { this.span = span; + this.openingThread = Thread.currentThread(); Scope openedScope = null; + Context openedContext = null; java.lang.ref.Cleaner.Cleanable registeredCleanable; try { // External OpenTelemetry SDKs do not install our SpanProcessor, so library-created spans // also apply the current immutable metadata directly. LangfuseContext.applyTo(span, LangfuseContext.current()); openedScope = span.makeCurrent(); - registeredCleanable = SpanGuard.register(this, span, openedScope, name, closed); + openedContext = Context.current(); + registeredCleanable = SpanGuard.register(this, span, name, closed); } catch (Throwable setupFailure) { try { if (openedScope != null) { @@ -39,26 +46,59 @@ protected AbstractLangfuseSpan(Span span, String name) { throw setupFailure; } this.scope = openedScope; + this.installedContext = openedContext; this.cleanable = registeredCleanable; } + /** + * Records the exception type and marks the span as failed. + * + * @param t exception to record; {@code null} is ignored + */ public void recordException(Throwable t) { ExceptionRecorder.recordTypeOnly(span, t); } + /** + * Returns the underlying OpenTelemetry span. + * + * @return the underlying OpenTelemetry span + */ public Span getSpan() { return span; } + /** Ends this scope; equivalent to {@link #close()}. */ public void end() { close(); } - // Delegates entirely to cleanable.clean() — see DESIGN.md #6 for why we don't call scope.close()/span.end() here + /** + * Restores the parent context and ends the span. + * + * @throws IllegalStateException if called from a different thread or while another + * OpenTelemetry context is current + */ @Override public void close() { + if (closed.get()) { + return; + } + if (Thread.currentThread() != openingThread) { + throw new IllegalStateException( + "Langfuse spans must be closed on the thread where they were created"); + } + // Scope.close() can restore its parent only while this exact attached Context is current. + if (Context.current() != installedContext) { + throw new IllegalStateException( + "Langfuse spans must be closed in reverse creation order"); + } if (closed.compareAndSet(false, true)) { - cleanable.clean(); + try { + scope.close(); + } finally { + cleanable.clean(); + } } } } diff --git a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/ContentCapturePolicy.java b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/ContentCapturePolicy.java index 7080a1b..d61f244 100644 --- a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/ContentCapturePolicy.java +++ b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/ContentCapturePolicy.java @@ -25,12 +25,20 @@ private ContentCapturePolicy(Builder builder) { this.redactor = builder.redactor; } - /** Returns the production-safe default, which records metadata but no input or output. */ + /** + * Returns the production-safe default, which records metadata but no input or output. + * + * @return a metadata-only policy + */ public static ContentCapturePolicy metadataOnly() { return builder().build(); } - /** Enables input and output capture with the finite default length limit. */ + /** + * Enables input and output capture with the finite default length limit. + * + * @return a policy that captures input and output + */ public static ContentCapturePolicy captureAll() { return builder() .captureInput(true) @@ -38,18 +46,38 @@ public static ContentCapturePolicy captureAll() { .build(); } + /** + * Creates a policy builder. + * + * @return a new builder + */ public static Builder builder() { return new Builder(); } + /** + * Returns whether automatic instrumentation may record input. + * + * @return {@code true} when input capture is enabled + */ public boolean isInputCaptureEnabled() { return inputCaptureEnabled; } + /** + * Returns whether automatic instrumentation may record output. + * + * @return {@code true} when output capture is enabled + */ public boolean isOutputCaptureEnabled() { return outputCaptureEnabled; } + /** + * Returns the post-redaction capture limit in UTF-16 code units. + * + * @return the configured limit + */ public int getMaxLength() { return maxLength; } @@ -94,6 +122,7 @@ private String truncateWithoutSplittingSurrogatePair(String content) { return content.substring(0, endIndex); } + /** Builds a content capture policy. */ public static final class Builder { private boolean inputCaptureEnabled; @@ -103,17 +132,35 @@ public static final class Builder { private Builder() {} + /** + * Enables or disables automatic input capture. + * + * @param enabled whether input may be recorded + * @return this builder + */ public Builder captureInput(boolean enabled) { this.inputCaptureEnabled = enabled; return this; } + /** + * Enables or disables automatic output capture. + * + * @param enabled whether output may be recorded + * @return this builder + */ public Builder captureOutput(boolean enabled) { this.outputCaptureEnabled = enabled; return this; } - /** Sets the maximum number of UTF-16 code units retained after redaction. */ + /** + * Sets the maximum number of UTF-16 code units retained after redaction. + * + * @param maxLength a positive capture limit + * @return this builder + * @throws IllegalArgumentException if {@code maxLength} is not positive + */ public Builder maxLength(int maxLength) { if (maxLength <= 0) { throw new IllegalArgumentException("maxLength must be greater than zero"); @@ -122,11 +169,23 @@ public Builder maxLength(int maxLength) { return this; } + /** + * Sets the redactor invoked before truncation. + * + * @param redactor a thread-safe redactor + * @return this builder + * @throws NullPointerException if {@code redactor} is {@code null} + */ public Builder redactor(ContentRedactor redactor) { this.redactor = Objects.requireNonNull(redactor, "redactor"); return this; } + /** + * Builds an immutable policy. + * + * @return the configured policy + */ public ContentCapturePolicy build() { return new ContentCapturePolicy(this); } diff --git a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/ContentCaptureType.java b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/ContentCaptureType.java index 8ac6e2d..fcd72f9 100644 --- a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/ContentCaptureType.java +++ b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/ContentCaptureType.java @@ -2,6 +2,8 @@ /** Identifies the automatic instrumentation content being processed. */ public enum ContentCaptureType { + /** Model or operation input. */ INPUT, + /** Model or operation output. */ OUTPUT } diff --git a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/ContentRedactor.java b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/ContentRedactor.java index 6562e11..89e94d5 100644 --- a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/ContentRedactor.java +++ b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/ContentRedactor.java @@ -10,6 +10,11 @@ public interface ContentRedactor { /** * Returns redacted content, or {@code null} to discard it. * Exceptions are contained by the capture policy and never reach the host application. + * + * @param type the content direction + * @param content the unredacted content + * @return redacted content, or {@code null} to discard it + * @throws Exception if redaction fails; the capture policy contains the exception */ String redact(ContentCaptureType type, String content) throws Exception; } diff --git a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/ExceptionCapturePolicy.java b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/ExceptionCapturePolicy.java index 88275c7..2aa7728 100644 --- a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/ExceptionCapturePolicy.java +++ b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/ExceptionCapturePolicy.java @@ -25,12 +25,20 @@ private ExceptionCapturePolicy(Builder builder) { this.redactor = builder.redactor; } - /** Returns the production-safe default, which records only the exception type. */ + /** + * Returns the production-safe default, which records only the exception type. + * + * @return a type-only policy + */ public static ExceptionCapturePolicy typeOnly() { return builder().build(); } - /** Enables message and stack-trace capture with the finite default length limit. */ + /** + * Enables message and stack-trace capture with the finite default length limit. + * + * @return a policy that captures messages and stack traces + */ public static ExceptionCapturePolicy captureAll() { return builder() .captureMessage(true) @@ -38,18 +46,38 @@ public static ExceptionCapturePolicy captureAll() { .build(); } + /** + * Creates a policy builder. + * + * @return a new builder + */ public static Builder builder() { return new Builder(); } + /** + * Returns whether exception messages may be recorded. + * + * @return {@code true} when message capture is enabled + */ public boolean isMessageCaptureEnabled() { return messageCaptureEnabled; } + /** + * Returns whether stack traces may be recorded. + * + * @return {@code true} when stack-trace capture is enabled + */ public boolean isStackTraceCaptureEnabled() { return stackTraceCaptureEnabled; } + /** + * Returns the post-redaction limit for each detail in UTF-16 code units. + * + * @return the configured limit + */ public int getMaxLength() { return maxLength; } @@ -91,6 +119,7 @@ private String truncateWithoutSplittingSurrogatePair(String content) { return content.substring(0, endIndex); } + /** Builds an exception capture policy. */ public static final class Builder { private boolean messageCaptureEnabled; @@ -100,17 +129,35 @@ public static final class Builder { private Builder() {} + /** + * Enables or disables exception message capture. + * + * @param enabled whether messages may be recorded + * @return this builder + */ public Builder captureMessage(boolean enabled) { this.messageCaptureEnabled = enabled; return this; } + /** + * Enables or disables stack-trace capture. + * + * @param enabled whether stack traces may be recorded + * @return this builder + */ public Builder captureStackTrace(boolean enabled) { this.stackTraceCaptureEnabled = enabled; return this; } - /** Sets the maximum number of UTF-16 code units retained per captured exception detail. */ + /** + * Sets the maximum number of UTF-16 code units retained per captured exception detail. + * + * @param maxLength a positive capture limit + * @return this builder + * @throws IllegalArgumentException if {@code maxLength} is not positive + */ public Builder maxLength(int maxLength) { if (maxLength <= 0) { throw new IllegalArgumentException("maxLength must be greater than zero"); @@ -119,11 +166,23 @@ public Builder maxLength(int maxLength) { return this; } + /** + * Sets the redactor invoked before truncation. + * + * @param redactor a thread-safe redactor + * @return this builder + * @throws NullPointerException if {@code redactor} is {@code null} + */ public Builder redactor(ExceptionRedactor redactor) { this.redactor = Objects.requireNonNull(redactor, "redactor"); return this; } + /** + * Builds an immutable policy. + * + * @return the configured policy + */ public ExceptionCapturePolicy build() { return new ExceptionCapturePolicy(this); } diff --git a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/ExceptionCaptureType.java b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/ExceptionCaptureType.java index a13a162..298b512 100644 --- a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/ExceptionCaptureType.java +++ b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/ExceptionCaptureType.java @@ -2,6 +2,8 @@ /** Identifies an exception detail being processed for automatic instrumentation. */ public enum ExceptionCaptureType { + /** Exception message. */ MESSAGE, + /** Rendered exception stack trace. */ STACK_TRACE } diff --git a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/ExceptionRedactor.java b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/ExceptionRedactor.java index adbe2bc..910a34b 100644 --- a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/ExceptionRedactor.java +++ b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/ExceptionRedactor.java @@ -10,6 +10,11 @@ public interface ExceptionRedactor { /** * Returns redacted content, or {@code null} to discard it. * Exceptions are contained by the capture policy and never reach the host application. + * + * @param type the exception detail type + * @param content the unredacted detail + * @return redacted content, or {@code null} to discard it + * @throws Exception if redaction fails; the capture policy contains the exception */ String redact(ExceptionCaptureType type, String content) throws Exception; } diff --git a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/JsonUtils.java b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/JsonUtils.java index 8266f22..d373feb 100644 --- a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/JsonUtils.java +++ b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/JsonUtils.java @@ -1,9 +1,16 @@ package io.github.chomingi.langfuse.otel; +/** Minimal JSON string escaping used by tracing adapters. */ public final class JsonUtils { private JsonUtils() {} + /** + * Escapes JSON string content without adding quotation marks. + * + * @param text text to escape; {@code null} is treated as empty + * @return escaped JSON string content + */ public static String escapeJson(String text) { if (text == null) return ""; StringBuilder sb = new StringBuilder(text.length()); diff --git a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseAttributes.java b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseAttributes.java index b2a7f1c..ba59e9d 100644 --- a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseAttributes.java +++ b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseAttributes.java @@ -1,44 +1,79 @@ package io.github.chomingi.langfuse.otel; +/** OpenTelemetry attribute names understood by Langfuse. */ public final class LangfuseAttributes { private LangfuseAttributes() {} + /** {@value}. */ public static final String TRACE_NAME = "langfuse.trace.name"; + /** {@value}. */ public static final String TRACE_USER_ID = "user.id"; + /** {@value}. */ public static final String TRACE_SESSION_ID = "session.id"; + /** {@value}. */ public static final String TRACE_TAGS = "langfuse.trace.tags"; + /** {@value}. */ public static final String TRACE_PUBLIC = "langfuse.trace.public"; + /** {@value}. */ public static final String TRACE_METADATA = "langfuse.trace.metadata"; + /** {@value}. */ public static final String TRACE_INPUT = "langfuse.trace.input"; + /** {@value}. */ public static final String TRACE_OUTPUT = "langfuse.trace.output"; + /** {@value}. */ public static final String OBSERVATION_TYPE = "langfuse.observation.type"; + /** {@value}. */ public static final String OBSERVATION_INPUT = "langfuse.observation.input"; + /** {@value}. */ public static final String OBSERVATION_OUTPUT = "langfuse.observation.output"; + /** {@value}. */ public static final String OBSERVATION_METADATA = "langfuse.observation.metadata"; + /** {@value}. */ public static final String OBSERVATION_LEVEL = "langfuse.observation.level"; + /** {@value}. */ public static final String OBSERVATION_STATUS_MESSAGE = "langfuse.observation.status_message"; + /** {@value}. */ public static final String OBSERVATION_MODEL = "langfuse.observation.model.name"; + /** {@value}. */ public static final String OBSERVATION_MODEL_PARAMETERS = "langfuse.observation.model.parameters"; + /** {@value}. */ public static final String OBSERVATION_USAGE_DETAILS = "langfuse.observation.usage_details"; + /** {@value}. */ public static final String OBSERVATION_COST_DETAILS = "langfuse.observation.cost_details"; + /** {@value}. */ public static final String OBSERVATION_PROMPT_NAME = "langfuse.observation.prompt.name"; + /** {@value}. */ public static final String OBSERVATION_PROMPT_VERSION = "langfuse.observation.prompt.version"; + /** {@value}. */ public static final String OBSERVATION_COMPLETION_START_TIME = "langfuse.observation.completion_start_time"; + /** {@value}. */ public static final String ENVIRONMENT = "langfuse.environment"; + /** {@value}. */ public static final String RELEASE = "langfuse.release"; + /** {@value}. */ public static final String VERSION = "langfuse.version"; + /** {@value}. */ public static final String GEN_AI_OPERATION_NAME = "gen_ai.operation.name"; + /** {@value}. */ public static final String GEN_AI_SYSTEM = "gen_ai.system"; + /** {@value}. */ public static final String GEN_AI_REQUEST_MODEL = "gen_ai.request.model"; + /** {@value}. */ public static final String GEN_AI_RESPONSE_MODEL = "gen_ai.response.model"; + /** {@value}. */ public static final String GEN_AI_REQUEST_TEMPERATURE = "gen_ai.request.temperature"; + /** {@value}. */ public static final String GEN_AI_REQUEST_MAX_TOKENS = "gen_ai.request.max_tokens"; + /** {@value}. */ public static final String GEN_AI_REQUEST_TOP_P = "gen_ai.request.top_p"; + /** {@value}. */ public static final String GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"; + /** {@value}. */ public static final String GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"; + /** {@value}. */ public static final String GEN_AI_USAGE_TOTAL_TOKENS = "gen_ai.usage.total_tokens"; } diff --git a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseContext.java b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseContext.java index 504e36d..7dc8a3a 100644 --- a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseContext.java +++ b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseContext.java @@ -34,12 +34,23 @@ public final class LangfuseContext { private LangfuseContext() {} - /** Returns the stable key used to store {@link LangfuseTraceContext} in a Reactor Context. */ + /** + * Returns the stable key used to store {@link LangfuseTraceContext} in a Reactor Context. + * + * @return the Reactor context key + */ public static Object reactorContextKey() { return REACTOR_CONTEXT_KEY; } - /** Adds Langfuse request metadata to an OpenTelemetry context. */ + /** + * Adds Langfuse request metadata to an OpenTelemetry context. + * + * @param context source context + * @param traceContext metadata to store + * @return a context containing the metadata + * @throws IllegalArgumentException if either argument is {@code null} + */ public static Context storeIn(Context context, LangfuseTraceContext traceContext) { if (context == null) { throw new IllegalArgumentException("context must not be null"); @@ -50,13 +61,22 @@ public static Context storeIn(Context context, LangfuseTraceContext traceContext return context.with(OTEL_CONTEXT_KEY, traceContext); } - /** Returns Langfuse request metadata stored in the supplied OpenTelemetry context, if any. */ + /** + * Returns metadata stored in an OpenTelemetry context. + * + * @param context context to read; may be {@code null} + * @return stored metadata, or {@code null} when absent + */ public static LangfuseTraceContext from(Context context) { return context == null ? null : context.get(OTEL_CONTEXT_KEY); } /** * Makes immutable Langfuse metadata current for synchronous work and restores the previous values on close. + * + * @param traceContext metadata to make current + * @return a scope that restores the previous metadata + * @throws IllegalArgumentException if {@code traceContext} is {@code null} */ public static Scope makeCurrent(LangfuseTraceContext traceContext) { if (traceContext == null) { @@ -103,36 +123,70 @@ public void close() { }; } + /** + * Sets the legacy thread-local user identifier. This mutation is ignored while immutable + * metadata installed outside {@link #makeCurrent(LangfuseTraceContext)} is current. + * + * @param userId user identifier; may be {@code null} + */ public static void setUserId(String userId) { if (!legacyMutationAllowed()) return; markLegacyOverride(); USER_ID.set(userId); } + /** + * Returns the current user identifier. + * + * @return the user identifier, or {@code null} + */ public static String getUserId() { if (hasLegacyOverride()) return USER_ID.get(); LangfuseTraceContext traceContext = from(Context.current()); return traceContext != null ? traceContext.getUserId() : USER_ID.get(); } + /** + * Sets the legacy thread-local session identifier. This mutation is ignored while immutable + * metadata installed outside {@link #makeCurrent(LangfuseTraceContext)} is current. + * + * @param sessionId session identifier; may be {@code null} + */ public static void setSessionId(String sessionId) { if (!legacyMutationAllowed()) return; markLegacyOverride(); SESSION_ID.set(sessionId); } + /** + * Returns the current session identifier. + * + * @return the session identifier, or {@code null} + */ public static String getSessionId() { if (hasLegacyOverride()) return SESSION_ID.get(); LangfuseTraceContext traceContext = from(Context.current()); return traceContext != null ? traceContext.getSessionId() : SESSION_ID.get(); } + /** + * Sets the legacy thread-local trace tags. This mutation is ignored while immutable metadata + * installed outside {@link #makeCurrent(LangfuseTraceContext)} is current. + * + * @param tags trace tags; must not be {@code null} + * @throws NullPointerException if {@code tags} is {@code null} + */ public static void setTags(String... tags) { if (!legacyMutationAllowed()) return; markLegacyOverride(); TAGS.set(Arrays.asList(tags)); } + /** + * Returns the current trace tags. + * + * @return trace tags, never {@code null} + */ public static List getTags() { if (hasLegacyOverride()) return getLocalTags(); LangfuseTraceContext traceContext = from(Context.current()); @@ -141,19 +195,34 @@ public static List getTags() { return tags != null ? tags : Collections.emptyList(); } + /** + * Sets the legacy thread-local environment. This mutation is ignored while immutable metadata + * installed outside {@link #makeCurrent(LangfuseTraceContext)} is current. + * + * @param environment environment name; may be {@code null} + */ public static void setEnvironment(String environment) { if (!legacyMutationAllowed()) return; markLegacyOverride(); ENVIRONMENT.set(environment); } + /** + * Returns the current environment. + * + * @return the environment, or {@code null} + */ public static String getEnvironment() { if (hasLegacyOverride()) return ENVIRONMENT.get(); LangfuseTraceContext traceContext = from(Context.current()); return traceContext != null ? traceContext.getEnvironment() : ENVIRONMENT.get(); } - /** Returns an immutable snapshot of the current OTel or legacy thread-local values. */ + /** + * Returns an immutable snapshot of the current OTel or legacy thread-local values. + * + * @return current request metadata + */ public static LangfuseTraceContext current() { if (hasLegacyOverride()) return legacyCurrent(); LangfuseTraceContext traceContext = from(Context.current()); @@ -170,6 +239,10 @@ static LangfuseTraceContext legacyCurrent() { .build(); } + /** + * Clears legacy thread-local metadata. This mutation is ignored while immutable metadata + * installed outside {@link #makeCurrent(LangfuseTraceContext)} is current. + */ public static void clear() { if (!legacyMutationAllowed()) return; markLegacyOverride(); @@ -183,7 +256,13 @@ static boolean hasLegacyOverride() { return Boolean.TRUE.equals(LEGACY_OVERRIDE.get()); } - /** Applies immutable Langfuse request metadata directly to a library-created span. */ + /** + * Applies immutable Langfuse request metadata directly to a library-created span. + * + * @param span destination span + * @param traceContext metadata to apply; {@code null} is ignored + * @throws IllegalArgumentException if {@code span} is {@code null} + */ public static void applyTo(Span span, LangfuseTraceContext traceContext) { if (span == null) { throw new IllegalArgumentException("span must not be null"); @@ -203,7 +282,12 @@ public static void applyTo(Span span, LangfuseTraceContext traceContext) { } } - /** Applies the current OTel or legacy Langfuse request metadata directly to a span. */ + /** + * Applies the current OTel or legacy Langfuse request metadata directly to a span. + * + * @param span destination span + * @throws IllegalArgumentException if {@code span} is {@code null} + */ public static void applyTo(Span span) { applyTo(span, current()); } diff --git a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseGeneration.java b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseGeneration.java index e5b4a8e..8db3227 100644 --- a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseGeneration.java +++ b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseGeneration.java @@ -6,10 +6,18 @@ /** * Represents an LLM generation (model invocation). Tracks model, input/output, tokens, and metadata. * Created via {@code trace.generation("name")} or directly with a Tracer for AOP use cases. + * + *

The generation is a synchronous, thread-bound scope. Close it on the thread where it was + * created and in reverse creation order relative to other Langfuse spans.

*/ public class LangfuseGeneration extends AbstractLangfuseSpan { - // Public: Spring AOP aspects in starter module need direct instantiation (DESIGN.md #11) + /** + * Starts a generation and makes it current. + * + * @param tracer tracer used to start the span + * @param name generation name + */ public LangfuseGeneration(Tracer tracer, String name) { super(tracer.spanBuilder(name) .setSpanKind(SpanKind.CLIENT) @@ -17,92 +25,203 @@ public LangfuseGeneration(Tracer tracer, String name) { .startSpan(), name); } + /** + * Records the GenAI operation name. + * + * @param operationName operation name + * @return this generation + */ public LangfuseGeneration operationName(String operationName) { span.setAttribute(LangfuseAttributes.GEN_AI_OPERATION_NAME, operationName); return this; } + /** + * Records the requested model. + * + * @param model model name + * @return this generation + */ public LangfuseGeneration model(String model) { span.setAttribute(LangfuseAttributes.GEN_AI_REQUEST_MODEL, model); return this; } + /** + * Records the model reported by the response. + * + * @param model model name + * @return this generation + */ public LangfuseGeneration responseModel(String model) { span.setAttribute(LangfuseAttributes.GEN_AI_RESPONSE_MODEL, model); return this; } + /** + * Records the model provider or system. + * + * @param system system name + * @return this generation + */ public LangfuseGeneration system(String system) { span.setAttribute(LangfuseAttributes.GEN_AI_SYSTEM, system); return this; } + /** + * Records the requested temperature. + * + * @param temperature temperature value + * @return this generation + */ public LangfuseGeneration temperature(double temperature) { span.setAttribute(LangfuseAttributes.GEN_AI_REQUEST_TEMPERATURE, temperature); return this; } + /** + * Records the requested maximum token count. + * + * @param maxTokens maximum tokens + * @return this generation + */ public LangfuseGeneration maxTokens(int maxTokens) { span.setAttribute(LangfuseAttributes.GEN_AI_REQUEST_MAX_TOKENS, (long) maxTokens); return this; } + /** + * Records the requested top-p value. + * + * @param topP top-p value + * @return this generation + */ public LangfuseGeneration topP(double topP) { span.setAttribute(LangfuseAttributes.GEN_AI_REQUEST_TOP_P, topP); return this; } + /** + * Records input directly, without applying the automatic capture policy. + * + * @param input input value + * @return this generation + */ public LangfuseGeneration input(Object input) { span.setAttribute(LangfuseAttributes.OBSERVATION_INPUT, String.valueOf(input)); return this; } + /** + * Records output directly, without applying the automatic capture policy. + * + * @param output output value + * @return this generation + */ public LangfuseGeneration output(Object output) { span.setAttribute(LangfuseAttributes.OBSERVATION_OUTPUT, String.valueOf(output)); return this; } + /** + * Records input token usage. + * + * @param tokens input tokens + * @return this generation + */ public LangfuseGeneration inputTokens(int tokens) { span.setAttribute(LangfuseAttributes.GEN_AI_USAGE_INPUT_TOKENS, (long) tokens); return this; } + /** + * Records output token usage. + * + * @param tokens output tokens + * @return this generation + */ public LangfuseGeneration outputTokens(int tokens) { span.setAttribute(LangfuseAttributes.GEN_AI_USAGE_OUTPUT_TOKENS, (long) tokens); return this; } + /** + * Records total token usage. + * + * @param tokens total tokens + * @return this generation + */ public LangfuseGeneration totalTokens(int tokens) { span.setAttribute(LangfuseAttributes.GEN_AI_USAGE_TOTAL_TOKENS, (long) tokens); return this; } + /** + * Links the generation to a Langfuse prompt. + * + * @param name prompt name + * @return this generation + */ public LangfuseGeneration promptName(String name) { span.setAttribute(LangfuseAttributes.OBSERVATION_PROMPT_NAME, name); return this; } + /** + * Records the Langfuse prompt version. + * + * @param version prompt version + * @return this generation + */ public LangfuseGeneration promptVersion(int version) { span.setAttribute(LangfuseAttributes.OBSERVATION_PROMPT_VERSION, (long) version); return this; } + /** + * Records observation metadata. + * + * @param key metadata key + * @param value metadata value + * @return this generation + */ public LangfuseGeneration metadata(String key, String value) { span.setAttribute(LangfuseAttributes.OBSERVATION_METADATA + "." + key, value); return this; } + /** + * Records the observation level. + * + * @param level level value + * @return this generation + */ public LangfuseGeneration level(String level) { span.setAttribute(LangfuseAttributes.OBSERVATION_LEVEL, level); return this; } + /** + * Records the observation status message. + * + * @param message status message + * @return this generation + */ public LangfuseGeneration statusMessage(String message) { span.setAttribute(LangfuseAttributes.OBSERVATION_STATUS_MESSAGE, message); return this; } - // Object type to avoid NoClassDefFoundError when langfuse-java is absent (DESIGN.md #12) + /** + * Creates a text prompt helper linked to this generation. + * + * @param langfuseClient a {@code com.langfuse.client.LangfuseClient} + * @param promptName prompt to fetch + * @return a prompt helper + * @throws IllegalStateException if the optional Langfuse client dependency is absent + * @throws ClassCastException if {@code langfuseClient} is not a Langfuse client + */ public LangfusePromptHelper prompt(Object langfuseClient, String promptName) { try { Class.forName("com.langfuse.client.LangfuseClient"); diff --git a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseOtel.java b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseOtel.java index 512dcf2..ec73fb1 100644 --- a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseOtel.java +++ b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseOtel.java @@ -5,6 +5,7 @@ import io.opentelemetry.api.trace.Tracer; import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.common.CompletableResultCode; import io.opentelemetry.sdk.resources.Resource; import io.opentelemetry.sdk.resources.ResourceBuilder; import io.opentelemetry.sdk.trace.SdkTracerProvider; @@ -62,6 +63,7 @@ public enum OpenTelemetryOwnership { private final OpenTelemetryOwnership openTelemetryOwnership; private final ContentCapturePolicy contentCapturePolicy; private final ExceptionCapturePolicy exceptionCapturePolicy; + private final LangfuseOtelRuntime runtime; LangfuseOtel(SdkTracerProvider tracerProvider, OpenTelemetry openTelemetry, Object langfuseClient, boolean noop) { @@ -70,14 +72,20 @@ public enum OpenTelemetryOwnership { ? OpenTelemetryOwnership.OWNED : (noop ? OpenTelemetryOwnership.NONE : OpenTelemetryOwnership.EXTERNAL), ContentCapturePolicy.captureAll(), - ExceptionCapturePolicy.typeOnly()); + ExceptionCapturePolicy.typeOnly(), + LangfuseOtelRuntime.unmonitored( + tracerProvider != null, + noop + ? LangfuseOtelStatus.NoopReason.INITIALIZATION_FAILURE + : LangfuseOtelStatus.NoopReason.NONE)); } private LangfuseOtel(SdkTracerProvider ownedTracerProvider, OpenTelemetry openTelemetry, Object langfuseClient, boolean noop, OpenTelemetryOwnership openTelemetryOwnership, ContentCapturePolicy contentCapturePolicy, - ExceptionCapturePolicy exceptionCapturePolicy) { + ExceptionCapturePolicy exceptionCapturePolicy, + LangfuseOtelRuntime runtime) { this.ownedTracerProvider = ownedTracerProvider; this.tracer = openTelemetry.getTracer(TRACER_NAME, LIB_VERSION); this.langfuseClient = langfuseClient; @@ -85,19 +93,32 @@ private LangfuseOtel(SdkTracerProvider ownedTracerProvider, OpenTelemetry openTe this.openTelemetryOwnership = openTelemetryOwnership; this.contentCapturePolicy = Objects.requireNonNull(contentCapturePolicy, "contentCapturePolicy"); this.exceptionCapturePolicy = Objects.requireNonNull(exceptionCapturePolicy, "exceptionCapturePolicy"); + this.runtime = Objects.requireNonNull(runtime, "runtime"); } private static LangfuseOtel createNoop(ContentCapturePolicy contentCapturePolicy, - ExceptionCapturePolicy exceptionCapturePolicy) { + ExceptionCapturePolicy exceptionCapturePolicy, + LangfuseOtelStatus.NoopReason reason) { return new LangfuseOtel(null, OpenTelemetry.noop(), null, true, - OpenTelemetryOwnership.NONE, contentCapturePolicy, exceptionCapturePolicy); + OpenTelemetryOwnership.NONE, contentCapturePolicy, exceptionCapturePolicy, + LangfuseOtelRuntime.unmonitored(false, reason)); } + /** + * Returns the optional Langfuse Java client retained by this instance. Fail-safe no-op + * instances return {@code null}. + * + * @return the client, or {@code null} + */ public Object getLangfuseClient() { return langfuseClient; } - /** Creates a new builder for configuring the Langfuse OTel integration. */ + /** + * Creates a builder for a standalone OpenTelemetry pipeline. + * + * @return a new builder + */ public static Builder builder() { return new Builder(); } @@ -109,6 +130,8 @@ public static Builder builder() { * Standalone transport settings such as API keys, host, and service name are ignored. * * @param openTelemetry the application-owned OpenTelemetry instance + * @return a builder using the supplied instance + * @throws NullPointerException if {@code openTelemetry} is {@code null} */ public static Builder externalBuilder(OpenTelemetry openTelemetry) { Builder builder = new Builder(); @@ -121,64 +144,130 @@ private static String resolveLibraryVersion() { return version != null ? version : "dev"; } + /** + * Returns this integration's tracer. + * + * @return the tracer, including for no-op instances + */ public Tracer getTracer() { return tracer; } + /** + * Returns whether construction fell back to a no-op instance. + * + * @return {@code true} for a no-op instance + */ public boolean isNoop() { return noop; } - /** Returns the lifecycle ownership mode for the OpenTelemetry instance in use. */ + /** + * Returns the lifecycle ownership mode for the OpenTelemetry instance in use. + * + * @return the ownership mode + */ public OpenTelemetryOwnership getOpenTelemetryOwnership() { return openTelemetryOwnership; } - /** Returns whether this instance created and owns its OpenTelemetry SDK. */ + /** + * Returns whether this instance created and owns its OpenTelemetry SDK. + * + * @return {@code true} when this instance owns the SDK + */ public boolean ownsOpenTelemetry() { return openTelemetryOwnership == OpenTelemetryOwnership.OWNED; } - /** Returns the policy applied only to content recorded by automatic instrumentation. */ + /** + * Returns an immutable snapshot of ownership, fallback, exporter, queue, and flush state. + * Exporter and queue signals are deliberately not inferred for application-owned pipelines. + * + * @return the current operational snapshot + */ + public LangfuseOtelStatus getStatus() { + return runtime.snapshot(openTelemetryOwnership, noop); + } + + /** + * Returns the policy applied only to content recorded by automatic instrumentation. + * + * @return the content capture policy + */ public ContentCapturePolicy getContentCapturePolicy() { return contentCapturePolicy; } - /** Returns the policy applied to exceptions recorded by automatic instrumentation. */ + /** + * Returns the policy applied to exceptions recorded by automatic instrumentation. + * + * @return the exception capture policy + */ public ExceptionCapturePolicy getExceptionCapturePolicy() { return exceptionCapturePolicy; } - /** Safely records automatic-instrumentation input according to the configured policy. */ + /** + * Safely records automatic-instrumentation input according to the configured policy. + * + * @param span destination span; {@code null} is ignored + * @param input input value + */ public void recordInput(Span span, Object input) { recordContent(span, ContentCaptureType.INPUT, LangfuseAttributes.OBSERVATION_INPUT, input); } - /** Safely records automatic-instrumentation output according to the configured policy. */ + /** + * Safely records automatic-instrumentation output according to the configured policy. + * + * @param span destination span; {@code null} is ignored + * @param output output value + */ public void recordOutput(Span span, Object output) { recordContent(span, ContentCaptureType.OUTPUT, LangfuseAttributes.OBSERVATION_OUTPUT, output); } - /** Safely records generation input according to the automatic content policy. */ + /** + * Safely records generation input according to the automatic content policy. + * + * @param generation destination generation; {@code null} is ignored + * @param input input value + */ public void recordInput(LangfuseGeneration generation, Object input) { if (generation != null) { recordInput(generation.getSpan(), input); } } - /** Safely records generation output according to the automatic content policy. */ + /** + * Safely records generation output according to the automatic content policy. + * + * @param generation destination generation; {@code null} is ignored + * @param output output value + */ public void recordOutput(LangfuseGeneration generation, Object output) { if (generation != null) { recordOutput(generation.getSpan(), output); } } - /** Safely records an automatic-instrumentation exception according to the configured policy. */ + /** + * Safely records an automatic-instrumentation exception according to the configured policy. + * + * @param span destination span; {@code null} is ignored + * @param throwable exception to record; {@code null} is ignored + */ public void recordException(Span span, Throwable throwable) { ExceptionRecorder.record(span, throwable, exceptionCapturePolicy); } - /** Safely records a generation exception according to the automatic exception policy. */ + /** + * Safely records a generation exception according to the automatic exception policy. + * + * @param generation destination generation; {@code null} is ignored + * @param throwable exception to record; {@code null} is ignored + */ public void recordException(LangfuseGeneration generation, Throwable throwable) { if (generation != null) { recordException(generation.getSpan(), throwable); @@ -199,12 +288,23 @@ private void recordContent(Span span, ContentCaptureType type, String attributeN } } - /** Creates a new trace. Caller must close the returned trace (try-with-resources or {@code end()}). */ + /** + * Creates a synchronous, thread-bound trace scope. The caller must close it on the creating + * thread, after its children, using try-with-resources or {@code end()}. + * + * @param name trace name + * @return the new trace + */ public LangfuseTrace trace(String name) { return new LangfuseTrace(tracer, name); } - /** Creates a trace, executes the action, and automatically closes it. Exceptions propagate after recording. */ + /** + * Creates a trace, runs an action, and closes the trace. + * + * @param name trace name + * @param action action to run; runtime exceptions propagate after recording + */ public void trace(String name, Consumer action) { try (LangfuseTrace trace = new LangfuseTrace(tracer, name)) { try { @@ -216,11 +316,40 @@ public void trace(String name, Consumer action) { } } - /** Flushes only the internally owned SDK; external and no-op modes intentionally do nothing. */ + /** + * Requests a local flush only for the internally owned SDK; external and no-op modes do nothing. + * Local flush completion does not guarantee that the remote endpoint accepted every span. + */ public void flush() { if (openTelemetryOwnership == OpenTelemetryOwnership.OWNED && ownedTracerProvider != null) { - ownedTracerProvider.forceFlush().join(10, TimeUnit.SECONDS); + long sequence = runtime.beginFlush(); + try { + LangfuseOtelStatus.FlushState state = awaitFlush( + ownedTracerProvider.forceFlush(), 10, TimeUnit.SECONDS); + runtime.completeFlush(sequence, state); + } catch (RuntimeException | Error e) { + runtime.completeFlush(sequence, LangfuseOtelStatus.FlushState.FAILED); + throw e; + } + } + } + + static LangfuseOtelStatus.FlushState awaitFlush(CompletableResultCode result, + long timeout, + TimeUnit unit) { + if (result == null) { + return LangfuseOtelStatus.FlushState.FAILED; + } + result.join(timeout, unit); + if (!result.isDone()) { + if (Thread.currentThread().isInterrupted()) { + return LangfuseOtelStatus.FlushState.FAILED; + } + return LangfuseOtelStatus.FlushState.TIMED_OUT; } + return result.isSuccess() + ? LangfuseOtelStatus.FlushState.SUCCEEDED + : LangfuseOtelStatus.FlushState.FAILED; } /** Shuts down only the internally owned SDK; the application retains external lifecycle control. */ @@ -231,6 +360,7 @@ public void close() { } } + /** Configures a Langfuse OpenTelemetry integration. */ public static class Builder { private String publicKey; @@ -248,65 +378,154 @@ public static class Builder { private Builder() {} + /** + * Sets the Langfuse public API key for the standalone exporter. + * + * @param publicKey public API key + * @return this builder + */ public Builder publicKey(String publicKey) { this.publicKey = publicKey; return this; } + + /** + * Sets the Langfuse secret API key for the standalone exporter. + * + * @param secretKey secret API key + * @return this builder + */ public Builder secretKey(String secretKey) { this.secretKey = secretKey; return this; } + + /** + * Sets the Langfuse base URL. HTTPS is required by default. + * + * @param host absolute HTTP(S) base URL + * @return this builder + */ public Builder host(String host) { this.host = host; return this; } + + /** + * Sets the OpenTelemetry service name. + * + * @param serviceName service name + * @return this builder + */ public Builder serviceName(String serviceName) { this.serviceName = serviceName; return this; } + + /** + * Sets the deployment environment resource attribute. + * + * @param environment environment name + * @return this builder + */ public Builder environment(String environment) { this.environment = environment; return this; } + + /** + * Sets the release resource attribute. + * + * @param release release identifier + * @return this builder + */ public Builder release(String release) { this.release = release; return this; } + + /** + * Supplies an optional Langfuse Java client for prompt helpers. + * + * @param langfuseClient client instance + * @return this builder + */ public Builder langfuseClient(Object langfuseClient) { this.langfuseClient = langfuseClient; return this; } + + /** + * Controls whether invalid configuration or initialization failure yields a no-op instance. + * + * @param failSafe whether to fall back to no-op mode + * @return this builder + */ public Builder failSafe(boolean failSafe) { this.failSafe = failSafe; return this; } + /** * Allows a plaintext HTTP standalone endpoint on {@code localhost} or a literal loopback * address for local development only. * Production endpoints should always use HTTPS because API credentials are sent using * the HTTP {@code Authorization} header. + * + * @param allow whether local loopback HTTP is allowed + * @return this builder */ public Builder allowInsecureHttpForDevelopment(boolean allow) { this.allowInsecureHttpForDevelopment = allow; return this; } + /** + * Sets the policy for automatically captured model content. + * + * @param contentCapturePolicy capture policy + * @return this builder + * @throws NullPointerException if {@code contentCapturePolicy} is {@code null} + */ public Builder contentCapturePolicy(ContentCapturePolicy contentCapturePolicy) { this.contentCapturePolicy = Objects.requireNonNull(contentCapturePolicy, "contentCapturePolicy"); return this; } + /** + * Sets the policy for automatically captured exceptions. + * + * @param exceptionCapturePolicy capture policy + * @return this builder + * @throws NullPointerException if {@code exceptionCapturePolicy} is {@code null} + */ public Builder exceptionCapturePolicy(ExceptionCapturePolicy exceptionCapturePolicy) { this.exceptionCapturePolicy = Objects.requireNonNull(exceptionCapturePolicy, "exceptionCapturePolicy"); return this; } + /** + * Builds the integration. + * + * @return an active integration, or a no-op instance when fail-safe construction recovers + * @throws RuntimeException if initialization fails while fail-safe construction is disabled + */ public LangfuseOtel build() { if (externalOpenTelemetry != null) { try { return new LangfuseOtel(null, externalOpenTelemetry, langfuseClient, false, - OpenTelemetryOwnership.EXTERNAL, contentCapturePolicy, exceptionCapturePolicy); + OpenTelemetryOwnership.EXTERNAL, contentCapturePolicy, exceptionCapturePolicy, + LangfuseOtelRuntime.unmonitored(false, LangfuseOtelStatus.NoopReason.NONE)); } catch (RuntimeException e) { if (failSafe) { log.warn("Failed to initialize Langfuse with external OpenTelemetry. Running in no-op mode.", e); - return LangfuseOtel.createNoop(contentCapturePolicy, exceptionCapturePolicy); + return LangfuseOtel.createNoop(contentCapturePolicy, exceptionCapturePolicy, + LangfuseOtelStatus.NoopReason.INITIALIZATION_FAILURE); } throw e; } } - // failSafe=true: never crash the host app on misconfiguration (DESIGN.md #3) + // The default fail-safe mode keeps configuration errors from crashing the host application. if (publicKey == null || publicKey.isEmpty() || secretKey == null || secretKey.isEmpty()) { if (failSafe) { log.warn("Langfuse API keys not configured. Running in no-op mode — traces will not be sent."); - return LangfuseOtel.createNoop(contentCapturePolicy, exceptionCapturePolicy); + return LangfuseOtel.createNoop(contentCapturePolicy, exceptionCapturePolicy, + LangfuseOtelStatus.NoopReason.MISSING_CREDENTIALS); } throw new IllegalArgumentException("publicKey and secretKey are required"); } + OtlpHttpSpanExporter exporter = null; + SdkTracerProvider tracerProvider = null; try { String endpoint = buildOtlpEndpoint(host, allowInsecureHttpForDevelopment); String authHeader = "Basic " + Base64.getEncoder() .encodeToString((publicKey + ":" + secretKey).getBytes(StandardCharsets.UTF_8)); - OtlpHttpSpanExporter exporter = OtlpHttpSpanExporter.builder() + LangfuseOtelRuntime runtime = LangfuseOtelRuntime.managed(); + LangfuseOtelRuntimeMeterProvider runtimeMeterProvider = + new LangfuseOtelRuntimeMeterProvider(runtime); + + exporter = OtlpHttpSpanExporter.builder() .setEndpoint(endpoint) .addHeader("Authorization", authHeader) .addHeader("x-langfuse-ingestion-version", "4") + .setMeterProvider(runtimeMeterProvider) .build(); ResourceBuilder resourceBuilder = Resource.builder() @@ -319,10 +538,12 @@ public LangfuseOtel build() { } Resource resource = Resource.getDefault().merge(resourceBuilder.build()); - SdkTracerProvider tracerProvider = SdkTracerProvider.builder() + tracerProvider = SdkTracerProvider.builder() .setResource(resource) .addSpanProcessor(new LangfuseContextSpanProcessor()) - .addSpanProcessor(BatchSpanProcessor.builder(exporter).build()) + .addSpanProcessor(BatchSpanProcessor.builder(exporter) + .setMeterProvider(runtimeMeterProvider) + .build()) .build(); OpenTelemetrySdk otel = OpenTelemetrySdk.builder() @@ -330,16 +551,31 @@ public LangfuseOtel build() { .build(); return new LangfuseOtel(tracerProvider, otel, langfuseClient, false, - OpenTelemetryOwnership.OWNED, contentCapturePolicy, exceptionCapturePolicy); + OpenTelemetryOwnership.OWNED, contentCapturePolicy, exceptionCapturePolicy, runtime); } catch (Exception e) { + cleanUpFailedBuild(tracerProvider, exporter); if (failSafe) { log.warn("Failed to initialize Langfuse OTel. Running in no-op mode.", e); - return LangfuseOtel.createNoop(contentCapturePolicy, exceptionCapturePolicy); + return LangfuseOtel.createNoop(contentCapturePolicy, exceptionCapturePolicy, + LangfuseOtelStatus.NoopReason.INITIALIZATION_FAILURE); } throw e; } } + private static void cleanUpFailedBuild(SdkTracerProvider tracerProvider, + OtlpHttpSpanExporter exporter) { + try { + if (tracerProvider != null) { + tracerProvider.shutdown().join(10, TimeUnit.SECONDS); + } else if (exporter != null) { + exporter.shutdown().join(10, TimeUnit.SECONDS); + } + } catch (RuntimeException ignored) { + // Preserve the initialization failure that caused this cleanup. + } + } + private static String buildOtlpEndpoint(String configuredHost, boolean allowInsecureHttp) { if (configuredHost == null || configuredHost.isEmpty()) { throw new IllegalArgumentException("host must be a non-empty absolute HTTP(S) URI"); diff --git a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseOtelRuntime.java b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseOtelRuntime.java new file mode 100644 index 0000000..8eb222b --- /dev/null +++ b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseOtelRuntime.java @@ -0,0 +1,117 @@ +package io.github.chomingi.langfuse.otel; + +import java.util.Objects; + +import static io.github.chomingi.langfuse.otel.LangfuseOtelStatus.ExportState; +import static io.github.chomingi.langfuse.otel.LangfuseOtelStatus.FlushState; +import static io.github.chomingi.langfuse.otel.LangfuseOtelStatus.NoopReason; + +final class LangfuseOtelRuntime { + + private final boolean operationalSignalsAvailable; + private final boolean flushManaged; + private final NoopReason noopReason; + + private ExportState exportState; + private long failedExportSpanCount; + private long queueDroppedSpanCount; + private boolean queueDropsSinceLastSuccessfulExport; + + private FlushState lastFlushOutcome; + private long flushSequence; + private long latestFlushSequence; + private long flushesInProgress; + private long failedFlushCount; + private long timedOutFlushCount; + + private LangfuseOtelRuntime(boolean operationalSignalsAvailable, + boolean flushManaged, + NoopReason noopReason) { + this.operationalSignalsAvailable = operationalSignalsAvailable; + this.flushManaged = flushManaged; + this.noopReason = Objects.requireNonNull(noopReason, "noopReason"); + this.exportState = operationalSignalsAvailable + ? ExportState.NOT_ATTEMPTED + : ExportState.NOT_MANAGED; + this.lastFlushOutcome = flushManaged + ? FlushState.NOT_REQUESTED + : FlushState.NOT_MANAGED; + } + + static LangfuseOtelRuntime managed() { + return new LangfuseOtelRuntime(true, true, NoopReason.NONE); + } + + static LangfuseOtelRuntime unmonitored(boolean flushManaged, NoopReason noopReason) { + return new LangfuseOtelRuntime(false, flushManaged, noopReason); + } + + synchronized void recordExportCompleted(boolean success, long spanCount) { + if (!operationalSignalsAvailable || spanCount <= 0) { + return; + } + if (success) { + exportState = ExportState.SUCCEEDED; + queueDropsSinceLastSuccessfulExport = false; + } else { + failedExportSpanCount += spanCount; + exportState = ExportState.FAILED; + } + } + + synchronized void recordQueueDropped(long spanCount) { + if (!operationalSignalsAvailable || spanCount <= 0) { + return; + } + queueDroppedSpanCount += spanCount; + queueDropsSinceLastSuccessfulExport = true; + } + + synchronized long beginFlush() { + if (!flushManaged) { + return -1L; + } + long sequence = ++flushSequence; + latestFlushSequence = sequence; + flushesInProgress++; + return sequence; + } + + synchronized void completeFlush(long sequence, FlushState outcome) { + if (!flushManaged || sequence < 0) { + return; + } + if (flushesInProgress > 0) { + flushesInProgress--; + } + if (outcome == FlushState.TIMED_OUT) { + timedOutFlushCount++; + } else if (outcome == FlushState.FAILED) { + failedFlushCount++; + } + if (sequence == latestFlushSequence) { + lastFlushOutcome = outcome; + } + } + + LangfuseOtelStatus snapshot(LangfuseOtel.OpenTelemetryOwnership ownership, + boolean noopFallback) { + synchronized (this) { + FlushState visibleFlushState = flushesInProgress > 0 + ? FlushState.IN_PROGRESS + : lastFlushOutcome; + return new LangfuseOtelStatus( + ownership, + noopFallback, + noopReason, + operationalSignalsAvailable, + exportState, + failedExportSpanCount, + queueDroppedSpanCount, + queueDropsSinceLastSuccessfulExport, + visibleFlushState, + failedFlushCount, + timedOutFlushCount); + } + } +} diff --git a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseOtelRuntimeMeterProvider.java b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseOtelRuntimeMeterProvider.java new file mode 100644 index 0000000..217d5c6 --- /dev/null +++ b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseOtelRuntimeMeterProvider.java @@ -0,0 +1,213 @@ +package io.github.chomingi.langfuse.otel; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.DoubleCounterBuilder; +import io.opentelemetry.api.metrics.DoubleGaugeBuilder; +import io.opentelemetry.api.metrics.DoubleHistogramBuilder; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.LongCounterBuilder; +import io.opentelemetry.api.metrics.LongUpDownCounterBuilder; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.metrics.MeterBuilder; +import io.opentelemetry.api.metrics.MeterProvider; +import io.opentelemetry.api.metrics.ObservableLongCounter; +import io.opentelemetry.api.metrics.ObservableLongMeasurement; +import io.opentelemetry.context.Context; + +import java.util.Objects; +import java.util.function.Consumer; + +/** + * Converts the pinned OpenTelemetry SDK self-metrics into the stable runtime + * snapshot exposed by this library. Unrelated instruments remain no-op. + */ +final class LangfuseOtelRuntimeMeterProvider implements MeterProvider { + + private static final String BATCH_PROCESSOR_SCOPE = "io.opentelemetry.sdk.trace"; + private static final String OTLP_HTTP_EXPORTER_SCOPE = "io.opentelemetry.exporters.otlp-http"; + private static final String PROCESSED_SPANS = "processedSpans"; + private static final String EXPORTER_EXPORTED = "otlp.exporter.exported"; + + private static final AttributeKey DROPPED = AttributeKey.booleanKey("dropped"); + private static final AttributeKey PROCESSOR_TYPE = AttributeKey.stringKey("processorType"); + private static final AttributeKey TYPE = AttributeKey.stringKey("type"); + private static final AttributeKey SUCCESS = AttributeKey.booleanKey("success"); + + private final MeterProvider delegate = MeterProvider.noop(); + private final LangfuseOtelRuntime runtime; + + LangfuseOtelRuntimeMeterProvider(LangfuseOtelRuntime runtime) { + this.runtime = Objects.requireNonNull(runtime, "runtime"); + } + + @Override + public MeterBuilder meterBuilder(String instrumentationScopeName) { + return new RuntimeMeterBuilder( + instrumentationScopeName, + delegate.meterBuilder(instrumentationScopeName), + runtime); + } + + private static final class RuntimeMeterBuilder implements MeterBuilder { + + private final String scopeName; + private final MeterBuilder delegate; + private final LangfuseOtelRuntime runtime; + + private RuntimeMeterBuilder(String scopeName, MeterBuilder delegate, LangfuseOtelRuntime runtime) { + this.scopeName = scopeName; + this.delegate = delegate; + this.runtime = runtime; + } + + @Override + public MeterBuilder setSchemaUrl(String schemaUrl) { + delegate.setSchemaUrl(schemaUrl); + return this; + } + + @Override + public MeterBuilder setInstrumentationVersion(String instrumentationVersion) { + delegate.setInstrumentationVersion(instrumentationVersion); + return this; + } + + @Override + public Meter build() { + return new RuntimeMeter(scopeName, delegate.build(), runtime); + } + } + + private static final class RuntimeMeter implements Meter { + + private final String scopeName; + private final Meter delegate; + private final LangfuseOtelRuntime runtime; + + private RuntimeMeter(String scopeName, Meter delegate, LangfuseOtelRuntime runtime) { + this.scopeName = scopeName; + this.delegate = delegate; + this.runtime = runtime; + } + + @Override + public LongCounterBuilder counterBuilder(String name) { + return new RuntimeCounterBuilder(scopeName, name, delegate.counterBuilder(name), runtime); + } + + @Override + public LongUpDownCounterBuilder upDownCounterBuilder(String name) { + return delegate.upDownCounterBuilder(name); + } + + @Override + public DoubleHistogramBuilder histogramBuilder(String name) { + return delegate.histogramBuilder(name); + } + + @Override + public DoubleGaugeBuilder gaugeBuilder(String name) { + return delegate.gaugeBuilder(name); + } + } + + private static final class RuntimeCounterBuilder implements LongCounterBuilder { + + private final String scopeName; + private final String name; + private final LongCounterBuilder delegate; + private final LangfuseOtelRuntime runtime; + + private RuntimeCounterBuilder(String scopeName, String name, + LongCounterBuilder delegate, LangfuseOtelRuntime runtime) { + this.scopeName = scopeName; + this.name = name; + this.delegate = delegate; + this.runtime = runtime; + } + + @Override + public LongCounterBuilder setDescription(String description) { + delegate.setDescription(description); + return this; + } + + @Override + public LongCounterBuilder setUnit(String unit) { + delegate.setUnit(unit); + return this; + } + + @Override + public DoubleCounterBuilder ofDoubles() { + return delegate.ofDoubles(); + } + + @Override + public LongCounter build() { + return new RuntimeCounter(scopeName, name, delegate.build(), runtime); + } + + @Override + public ObservableLongCounter buildWithCallback(Consumer callback) { + return delegate.buildWithCallback(callback); + } + } + + private static final class RuntimeCounter implements LongCounter { + + private final String scopeName; + private final String name; + private final LongCounter delegate; + private final LangfuseOtelRuntime runtime; + + private RuntimeCounter(String scopeName, String name, LongCounter delegate, + LangfuseOtelRuntime runtime) { + this.scopeName = scopeName; + this.name = name; + this.delegate = delegate; + this.runtime = runtime; + } + + @Override + public void add(long value) { + delegate.add(value); + record(value, Attributes.empty()); + } + + @Override + public void add(long value, Attributes attributes) { + delegate.add(value, attributes); + record(value, attributes); + } + + @Override + public void add(long value, Attributes attributes, Context context) { + delegate.add(value, attributes, context); + record(value, attributes); + } + + private void record(long value, Attributes attributes) { + if (value <= 0 || attributes == null) { + return; + } + if (BATCH_PROCESSOR_SCOPE.equals(scopeName) && PROCESSED_SPANS.equals(name) + && "BatchSpanProcessor".equals(attributes.get(PROCESSOR_TYPE)) + && Boolean.TRUE.equals(attributes.get(DROPPED))) { + runtime.recordQueueDropped(value); + return; + } + if (!OTLP_HTTP_EXPORTER_SCOPE.equals(scopeName) + || !"span".equals(attributes.get(TYPE))) { + return; + } + if (EXPORTER_EXPORTED.equals(name)) { + Boolean success = attributes.get(SUCCESS); + if (success != null) { + runtime.recordExportCompleted(success, value); + } + } + } + } +} diff --git a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseOtelStatus.java b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseOtelStatus.java new file mode 100644 index 0000000..6086c6d --- /dev/null +++ b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseOtelStatus.java @@ -0,0 +1,197 @@ +package io.github.chomingi.langfuse.otel; + +/** + * Immutable operational snapshot for a {@link LangfuseOtel} instance. + * + *

Exporter and queue signals are available only when {@link LangfuseOtel} + * owns the standalone OpenTelemetry pipeline. External pipelines remain under + * application control and are reported as not managed rather than as healthy + * zero values. A successful flush describes local SDK drain completion only; + * exporter state independently reports the latest delivery result.

+ */ +public final class LangfuseOtelStatus { + + /** Explains why fail-safe construction returned a no-op instance. */ + public enum NoopReason { + /** The instance is active and did not fall back to no-op mode. */ + NONE, + /** Standalone credentials were absent. */ + MISSING_CREDENTIALS, + /** Initialization failed while fail-safe construction was enabled. */ + INITIALIZATION_FAILURE + } + + /** Describes the most recently completed standalone export. */ + public enum ExportState { + /** Exporter state belongs to an external pipeline or no active pipeline exists. */ + NOT_MANAGED, + /** No standalone export has completed yet. */ + NOT_ATTEMPTED, + /** The most recently completed standalone exporter call reported success. */ + SUCCEEDED, + /** The most recently completed standalone exporter call reported failure. */ + FAILED + } + + /** Describes the most recent explicit {@link LangfuseOtel#flush()} operation. */ + public enum FlushState { + /** Flush lifecycle belongs to an external pipeline or no active pipeline exists. */ + NOT_MANAGED, + /** No explicit standalone flush has been requested. */ + NOT_REQUESTED, + /** At least one standalone flush is currently waiting for completion. */ + IN_PROGRESS, + /** + * The most recent standalone SDK flush completed locally. This does not prove + * that the remote endpoint accepted every span; consult {@link ExportState} separately. + */ + SUCCEEDED, + /** The flush returned a failed result or the wait was interrupted. */ + FAILED, + /** The most recent standalone flush did not complete within the configured wait. */ + TIMED_OUT + } + + private final LangfuseOtel.OpenTelemetryOwnership openTelemetryOwnership; + private final boolean noopFallback; + private final NoopReason noopReason; + private final boolean operationalSignalsAvailable; + private final ExportState exportState; + private final long failedExportSpanCount; + private final long queueDroppedSpanCount; + private final boolean queueDropsSinceLastSuccessfulExport; + private final FlushState flushState; + private final long failedFlushCount; + private final long timedOutFlushCount; + + LangfuseOtelStatus(LangfuseOtel.OpenTelemetryOwnership openTelemetryOwnership, + boolean noopFallback, + NoopReason noopReason, + boolean operationalSignalsAvailable, + ExportState exportState, + long failedExportSpanCount, + long queueDroppedSpanCount, + boolean queueDropsSinceLastSuccessfulExport, + FlushState flushState, + long failedFlushCount, + long timedOutFlushCount) { + this.openTelemetryOwnership = openTelemetryOwnership; + this.noopFallback = noopFallback; + this.noopReason = noopReason; + this.operationalSignalsAvailable = operationalSignalsAvailable; + this.exportState = exportState; + this.failedExportSpanCount = failedExportSpanCount; + this.queueDroppedSpanCount = queueDroppedSpanCount; + this.queueDropsSinceLastSuccessfulExport = queueDropsSinceLastSuccessfulExport; + this.flushState = flushState; + this.failedFlushCount = failedFlushCount; + this.timedOutFlushCount = timedOutFlushCount; + } + + /** + * Returns who owns the OpenTelemetry lifecycle. + * + * @return the ownership mode + */ + public LangfuseOtel.OpenTelemetryOwnership getOpenTelemetryOwnership() { + return openTelemetryOwnership; + } + + /** + * Returns whether fail-safe construction produced a no-op instance. + * + * @return {@code true} for a fail-safe no-op + */ + public boolean isNoopFallback() { + return noopFallback; + } + + /** + * Returns why fail-safe construction produced a no-op instance. + * + * @return the no-op reason + */ + public NoopReason getNoopReason() { + return noopReason; + } + + /** + * Returns whether exporter, queue, and flush signals are managed by this instance. + * External OpenTelemetry pipelines deliberately return {@code false}. + * + * @return {@code true} when operational signals are available + */ + public boolean isOperationalSignalsAvailable() { + return operationalSignalsAvailable; + } + + /** + * Returns the most recently completed standalone export state. + * + * @return the export state + */ + public ExportState getExportState() { + return exportState; + } + + /** + * Returns spans contained in standalone exporter calls reported as failed. + * This counter is meaningful only when operational signals are available. + * + * @return cumulative spans in failed export calls + */ + public long getFailedExportSpanCount() { + return failedExportSpanCount; + } + + /** + * Returns the cumulative number of spans dropped when the standalone queue was full. + * This counter is meaningful only when operational signals are available. + * + * @return cumulative queue-dropped spans + */ + public long getQueueDroppedSpanCount() { + return queueDroppedSpanCount; + } + + /** + * Returns whether a queue drop was observed since startup or the latest successful export. + * A later success shows pipeline progress; it cannot recover spans that were already dropped. + * + * @return {@code true} when a queue drop has occurred since startup or the latest successful + * export + */ + public boolean hasQueueDropsSinceLastSuccessfulExport() { + return queueDropsSinceLastSuccessfulExport; + } + + /** + * Returns the most recent explicit standalone flush state. + * Success means local SDK drain completion, not guaranteed remote delivery. + * + * @return the flush state + */ + public FlushState getFlushState() { + return flushState; + } + + /** + * Returns explicit owned-pipeline flushes that failed or were interrupted. + * External and no-op values are not observations of their pipelines. + * + * @return cumulative failed flushes + */ + public long getFailedFlushCount() { + return failedFlushCount; + } + + /** + * Returns explicit owned-pipeline flushes that exceeded the local wait timeout. + * External and no-op values are not observations of their pipelines. + * + * @return cumulative timed-out flushes + */ + public long getTimedOutFlushCount() { + return timedOutFlushCount; + } +} diff --git a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfusePromptHelper.java b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfusePromptHelper.java index a3f407c..eb24d6b 100644 --- a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfusePromptHelper.java +++ b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfusePromptHelper.java @@ -9,6 +9,12 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +/** + * Fetches and compiles a Langfuse text prompt for a generation. + * + *

Unknown placeholders are retained. Compilation records the prompt name, version, and + * compiled input on the linked generation.

+ */ public class LangfusePromptHelper { private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\{\\{\\s*([\\w.-]+)\\s*}}"); @@ -24,11 +30,24 @@ public class LangfusePromptHelper { this.generation = generation; } + /** + * Supplies a prompt variable. + * + * @param key variable name + * @param value replacement value + * @return this helper + */ public LangfusePromptHelper variable(String key, String value) { variables.put(key, value); return this; } + /** + * Fetches and compiles the configured text prompt. + * + * @return compiled prompt text + * @throws IllegalStateException if the fetched prompt is not a text prompt + */ public String compile() { Prompt prompt = client.prompts().get(promptName); diff --git a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseSpan.java b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseSpan.java index bd255ce..26503e5 100644 --- a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseSpan.java +++ b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseSpan.java @@ -8,6 +8,9 @@ /** * A generic span within a trace. Use for non-LLM steps (preprocessing, postprocessing, tool calls). * Can contain nested spans and generations. + * + *

The span is a synchronous, thread-bound scope. Close it on the thread where it was + * created, after all child spans and generations have been closed.

*/ public class LangfuseSpan extends AbstractLangfuseSpan { @@ -18,35 +21,79 @@ public class LangfuseSpan extends AbstractLangfuseSpan { this.tracer = tracer; } + /** + * Records input directly, without applying the automatic capture policy. + * + * @param input input value + * @return this span + */ public LangfuseSpan input(Object input) { span.setAttribute(LangfuseAttributes.OBSERVATION_INPUT, String.valueOf(input)); return this; } + /** + * Records output directly, without applying the automatic capture policy. + * + * @param output output value + * @return this span + */ public LangfuseSpan output(Object output) { span.setAttribute(LangfuseAttributes.OBSERVATION_OUTPUT, String.valueOf(output)); return this; } + /** + * Records observation metadata. + * + * @param key metadata key + * @param value metadata value + * @return this span + */ public LangfuseSpan metadata(String key, String value) { span.setAttribute(LangfuseAttributes.OBSERVATION_METADATA + "." + key, value); return this; } + /** + * Records the observation level. + * + * @param level level value + * @return this span + */ public LangfuseSpan level(String level) { span.setAttribute(LangfuseAttributes.OBSERVATION_LEVEL, level); return this; } + /** + * Records the observation status message. + * + * @param message status message + * @return this span + */ public LangfuseSpan statusMessage(String message) { span.setAttribute(LangfuseAttributes.OBSERVATION_STATUS_MESSAGE, message); return this; } + /** + * Starts a generation using the current OpenTelemetry context as its parent and makes it + * current. + * + * @param name generation name + * @return the generation; the caller must close it + */ public LangfuseGeneration generation(String name) { return new LangfuseGeneration(tracer, name); } + /** + * Runs an action in a generation parented by the current OpenTelemetry context. + * + * @param name generation name + * @param action action to run + */ public void generation(String name, Consumer action) { try (LangfuseGeneration gen = new LangfuseGeneration(tracer, name)) { try { @@ -58,10 +105,22 @@ public void generation(String name, Consumer action) { } } + /** + * Starts a span using the current OpenTelemetry context as its parent and makes it current. + * + * @param name span name + * @return the span; the caller must close it + */ public LangfuseSpan span(String name) { return new LangfuseSpan(tracer, name); } + /** + * Runs an action in a span parented by the current OpenTelemetry context. + * + * @param name span name + * @param action action to run + */ public void span(String name, Consumer action) { try (LangfuseSpan s = new LangfuseSpan(tracer, name)) { try { diff --git a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseTrace.java b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseTrace.java index 9f27f26..4eecf32 100644 --- a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseTrace.java +++ b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseTrace.java @@ -10,6 +10,9 @@ /** * Root span of a Langfuse trace. Contains child spans and generations. * Automatically inherits userId, sessionId, tags from {@link LangfuseContext}. + * + *

The trace is a synchronous, thread-bound scope. Close it on the thread where it was + * created, after all child spans and generations have been closed.

*/ public class LangfuseTrace extends AbstractLangfuseSpan { @@ -23,40 +26,91 @@ public class LangfuseTrace extends AbstractLangfuseSpan { this.tracer = tracer; } + /** + * Records the trace user identifier. + * + * @param userId user identifier + * @return this trace + */ public LangfuseTrace userId(String userId) { span.setAttribute(LangfuseAttributes.TRACE_USER_ID, userId); return this; } + /** + * Records the trace session identifier. + * + * @param sessionId session identifier + * @return this trace + */ public LangfuseTrace sessionId(String sessionId) { span.setAttribute(LangfuseAttributes.TRACE_SESSION_ID, sessionId); return this; } + /** + * Records trace tags. + * + * @param tags trace tags; must not be {@code null} + * @return this trace + * @throws NullPointerException if {@code tags} is {@code null} + */ public LangfuseTrace tags(String... tags) { span.setAttribute(AttributeKey.stringArrayKey(LangfuseAttributes.TRACE_TAGS), Arrays.asList(tags)); return this; } + /** + * Records input directly, without applying the automatic capture policy. + * + * @param input input value + * @return this trace + */ public LangfuseTrace input(Object input) { span.setAttribute(LangfuseAttributes.TRACE_INPUT, String.valueOf(input)); return this; } + /** + * Records output directly, without applying the automatic capture policy. + * + * @param output output value + * @return this trace + */ public LangfuseTrace output(Object output) { span.setAttribute(LangfuseAttributes.TRACE_OUTPUT, String.valueOf(output)); return this; } + /** + * Records trace metadata. + * + * @param key metadata key + * @param value metadata value + * @return this trace + */ public LangfuseTrace metadata(String key, String value) { span.setAttribute(LangfuseAttributes.TRACE_METADATA + "." + key, value); return this; } + /** + * Starts a generation using the current OpenTelemetry context as its parent and makes it + * current. + * + * @param name generation name + * @return the generation; the caller must close it + */ public LangfuseGeneration generation(String name) { return new LangfuseGeneration(tracer, name); } + /** + * Runs an action in a generation parented by the current OpenTelemetry context. + * + * @param name generation name + * @param action action to run + */ public void generation(String name, Consumer action) { try (LangfuseGeneration gen = new LangfuseGeneration(tracer, name)) { try { @@ -68,10 +122,22 @@ public void generation(String name, Consumer action) { } } + /** + * Starts a span using the current OpenTelemetry context as its parent and makes it current. + * + * @param name span name + * @return the span; the caller must close it + */ public LangfuseSpan span(String name) { return new LangfuseSpan(tracer, name); } + /** + * Runs an action in a span parented by the current OpenTelemetry context. + * + * @param name span name + * @param action action to run + */ public void span(String name, Consumer action) { try (LangfuseSpan s = new LangfuseSpan(tracer, name)) { try { diff --git a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseTraceContext.java b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseTraceContext.java index 3dd2475..190f6ce 100644 --- a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseTraceContext.java +++ b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/LangfuseTraceContext.java @@ -25,26 +25,52 @@ private LangfuseTraceContext(Builder builder) { this.environment = builder.environment; } + /** + * Creates a request metadata builder. + * + * @return a new builder + */ public static Builder builder() { return new Builder(); } + /** + * Returns the user identifier. + * + * @return the user identifier, or {@code null} + */ public String getUserId() { return userId; } + /** + * Returns the session identifier. + * + * @return the session identifier, or {@code null} + */ public String getSessionId() { return sessionId; } + /** + * Returns the immutable trace tags. + * + * @return trace tags, never {@code null} + */ public List getTags() { return tags; } + /** + * Returns the environment. + * + * @return the environment, or {@code null} + */ public String getEnvironment() { return environment; } + /** Builds immutable request metadata. */ public static final class Builder { private String userId; private String sessionId; @@ -53,31 +79,66 @@ public static final class Builder { private Builder() {} + /** + * Sets the user identifier. + * + * @param userId user identifier; may be {@code null} + * @return this builder + */ public Builder userId(String userId) { this.userId = userId; return this; } + /** + * Sets the session identifier. + * + * @param sessionId session identifier; may be {@code null} + * @return this builder + */ public Builder sessionId(String sessionId) { this.sessionId = sessionId; return this; } + /** + * Sets trace tags. + * + * @param tags tags; {@code null} clears them + * @return this builder + */ public Builder tags(String... tags) { this.tags = tags == null ? Collections.emptyList() : Arrays.asList(tags); return this; } + /** + * Sets trace tags. + * + * @param tags tags; {@code null} clears them + * @return this builder + */ public Builder tags(List tags) { this.tags = tags == null ? Collections.emptyList() : tags; return this; } + /** + * Sets the environment. + * + * @param environment environment name; may be {@code null} + * @return this builder + */ public Builder environment(String environment) { this.environment = environment; return this; } + /** + * Builds an immutable metadata value. + * + * @return the configured metadata + */ public LangfuseTraceContext build() { return new LangfuseTraceContext(this); } diff --git a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/SpanGuard.java b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/SpanGuard.java index 839ff84..5e5b7e8 100644 --- a/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/SpanGuard.java +++ b/langfuse-otel-core/src/main/java/io/github/chomingi/langfuse/otel/SpanGuard.java @@ -1,7 +1,6 @@ package io.github.chomingi.langfuse.otel; import io.opentelemetry.api.trace.Span; -import io.opentelemetry.context.Scope; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,19 +14,17 @@ final class SpanGuard { private SpanGuard() {} - static Cleaner.Cleanable register(Object owner, Span span, Scope scope, String spanName, AtomicBoolean closed) { - return CLEANER.register(owner, new CleanAction(span, scope, spanName, closed)); + static Cleaner.Cleanable register(Object owner, Span span, String spanName, AtomicBoolean closed) { + return CLEANER.register(owner, new CleanAction(span, spanName, closed)); } private static class CleanAction implements Runnable { private final Span span; - private final Scope scope; private final String spanName; private final AtomicBoolean closed; - CleanAction(Span span, Scope scope, String spanName, AtomicBoolean closed) { + CleanAction(Span span, String spanName, AtomicBoolean closed) { this.span = span; - this.scope = scope; this.spanName = spanName; this.closed = closed; } @@ -40,15 +37,7 @@ public void run() { + "cannot be restored by the Cleaner. Use try-with-resources, callback API, or call " + "end() explicitly.", spanName); } - try { - // OTel Scope is thread-affine. Cleaners run on a different thread and therefore - // cannot restore the originating thread's Context safely. - if (explicitlyClosed) { - scope.close(); - } - } finally { - span.end(); - } + span.end(); } } } diff --git a/langfuse-otel-core/src/test/java/io/github/chomingi/langfuse/otel/LangfuseOtelRuntimeMeterProviderTest.java b/langfuse-otel-core/src/test/java/io/github/chomingi/langfuse/otel/LangfuseOtelRuntimeMeterProviderTest.java new file mode 100644 index 0000000..3a7fa83 --- /dev/null +++ b/langfuse-otel-core/src/test/java/io/github/chomingi/langfuse/otel/LangfuseOtelRuntimeMeterProviderTest.java @@ -0,0 +1,76 @@ +package io.github.chomingi.langfuse.otel; + +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import org.junit.jupiter.api.Test; + +import java.util.Collection; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +class LangfuseOtelRuntimeMeterProviderTest { + + @Test + void batchProcessorSelfMetricsReportExactQueueDrops() throws Exception { + LangfuseOtelRuntime runtime = LangfuseOtelRuntime.managed(); + LangfuseOtelRuntimeMeterProvider meterProvider = new LangfuseOtelRuntimeMeterProvider(runtime); + BlockingExporter exporter = new BlockingExporter(); + BatchSpanProcessor processor = BatchSpanProcessor.builder(exporter) + .setMeterProvider(meterProvider) + .setScheduleDelay(1, TimeUnit.MILLISECONDS) + .setMaxQueueSize(1) + .setMaxExportBatchSize(1) + .build(); + SdkTracerProvider tracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(processor) + .build(); + + try { + Tracer tracer = tracerProvider.get("queue-drop-contract"); + tracer.spanBuilder("in-flight").startSpan().end(); + assertThat(exporter.started.await(5, TimeUnit.SECONDS)).isTrue(); + + tracer.spanBuilder("queued").startSpan().end(); + tracer.spanBuilder("dropped").startSpan().end(); + + LangfuseOtelStatus saturated = runtime.snapshot( + LangfuseOtel.OpenTelemetryOwnership.OWNED, false); + assertThat(saturated.getQueueDroppedSpanCount()).isEqualTo(1); + assertThat(saturated.hasQueueDropsSinceLastSuccessfulExport()).isTrue(); + + exporter.release.succeed(); + assertThat(tracerProvider.forceFlush().join(5, TimeUnit.SECONDS).isSuccess()).isTrue(); + } finally { + exporter.release.succeed(); + tracerProvider.shutdown().join(5, TimeUnit.SECONDS); + } + } + + private static final class BlockingExporter implements SpanExporter { + + private final CountDownLatch started = new CountDownLatch(1); + private final CompletableResultCode release = new CompletableResultCode(); + + @Override + public CompletableResultCode export(Collection spans) { + started.countDown(); + return release; + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode shutdown() { + return CompletableResultCode.ofSuccess(); + } + } +} diff --git a/langfuse-otel-core/src/test/java/io/github/chomingi/langfuse/otel/LangfuseOtelStatusTest.java b/langfuse-otel-core/src/test/java/io/github/chomingi/langfuse/otel/LangfuseOtelStatusTest.java new file mode 100644 index 0000000..783dec7 --- /dev/null +++ b/langfuse-otel-core/src/test/java/io/github/chomingi/langfuse/otel/LangfuseOtelStatusTest.java @@ -0,0 +1,207 @@ +package io.github.chomingi.langfuse.otel; + +import com.sun.net.httpserver.HttpServer; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.sdk.common.CompletableResultCode; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static io.github.chomingi.langfuse.otel.LangfuseOtel.OpenTelemetryOwnership.EXTERNAL; +import static io.github.chomingi.langfuse.otel.LangfuseOtel.OpenTelemetryOwnership.NONE; +import static io.github.chomingi.langfuse.otel.LangfuseOtel.OpenTelemetryOwnership.OWNED; +import static io.github.chomingi.langfuse.otel.LangfuseOtelStatus.ExportState.FAILED; +import static io.github.chomingi.langfuse.otel.LangfuseOtelStatus.ExportState.NOT_MANAGED; +import static io.github.chomingi.langfuse.otel.LangfuseOtelStatus.ExportState.SUCCEEDED; +import static io.github.chomingi.langfuse.otel.LangfuseOtelStatus.FlushState.NOT_REQUESTED; +import static io.github.chomingi.langfuse.otel.LangfuseOtelStatus.FlushState.TIMED_OUT; +import static io.github.chomingi.langfuse.otel.LangfuseOtelStatus.NoopReason.INITIALIZATION_FAILURE; +import static io.github.chomingi.langfuse.otel.LangfuseOtelStatus.NoopReason.MISSING_CREDENTIALS; +import static org.assertj.core.api.Assertions.assertThat; + +class LangfuseOtelStatusTest { + + @Test + void noopAndExternalModesDoNotInventOwnedPipelineSignals() { + try (LangfuseOtel noop = LangfuseOtel.builder().build(); + LangfuseOtel external = LangfuseOtel.externalBuilder(OpenTelemetry.noop()).build()) { + LangfuseOtelStatus noopStatus = noop.getStatus(); + assertThat(noopStatus.getOpenTelemetryOwnership()).isEqualTo(NONE); + assertThat(noopStatus.isNoopFallback()).isTrue(); + assertThat(noopStatus.getNoopReason()).isEqualTo(MISSING_CREDENTIALS); + assertThat(noopStatus.isOperationalSignalsAvailable()).isFalse(); + assertThat(noopStatus.getExportState()).isEqualTo(NOT_MANAGED); + assertThat(noopStatus.getFlushState()) + .isEqualTo(LangfuseOtelStatus.FlushState.NOT_MANAGED); + + LangfuseOtelStatus externalStatus = external.getStatus(); + assertThat(externalStatus.getOpenTelemetryOwnership()).isEqualTo(EXTERNAL); + assertThat(externalStatus.isNoopFallback()).isFalse(); + assertThat(externalStatus.isOperationalSignalsAvailable()).isFalse(); + assertThat(externalStatus.getExportState()).isEqualTo(NOT_MANAGED); + assertThat(externalStatus.getFlushState()) + .isEqualTo(LangfuseOtelStatus.FlushState.NOT_MANAGED); + } + } + + @Test + void initializationFailureUsesBoundedNoopReason() { + try (LangfuseOtel langfuse = LangfuseOtel.builder() + .publicKey("pk-test") + .secretKey("sk-test") + .host("http://example.com") + .allowInsecureHttpForDevelopment(true) + .build()) { + assertThat(langfuse.getStatus().getNoopReason()).isEqualTo(INITIALIZATION_FAILURE); + } + } + + @Test + void successfulStandaloneExportAndFlushUpdateIndependentStates() throws Exception { + CountDownLatch requestReceived = new CountDownLatch(1); + HttpServer receiver = startReceiver(200, requestReceived); + try { + try (LangfuseOtel langfuse = standalone(receiver)) { + assertThat(langfuse.getStatus().getFlushState()).isEqualTo(NOT_REQUESTED); + + langfuse.trace("successful-status-export", trace -> {}); + langfuse.flush(); + + assertThat(requestReceived.await(5, TimeUnit.SECONDS)).isTrue(); + LangfuseOtelStatus status = langfuse.getStatus(); + assertThat(status.getOpenTelemetryOwnership()).isEqualTo(OWNED); + assertThat(status.isOperationalSignalsAvailable()).isTrue(); + assertThat(status.getExportState()).isEqualTo(SUCCEEDED); + assertThat(status.getFailedExportSpanCount()).isZero(); + assertThat(status.getQueueDroppedSpanCount()).isZero(); + assertThat(status.getFlushState()) + .isEqualTo(LangfuseOtelStatus.FlushState.SUCCEEDED); + } + } finally { + receiver.stop(0); + } + } + + @Test + void exporterFailureIsVisibleEvenWhenBatchProcessorFlushCompletes() throws Exception { + CountDownLatch requestReceived = new CountDownLatch(1); + HttpServer receiver = startReceiver(401, requestReceived); + try { + try (LangfuseOtel langfuse = standalone(receiver)) { + langfuse.trace("failed-status-export", trace -> {}); + langfuse.flush(); + + assertThat(requestReceived.await(5, TimeUnit.SECONDS)).isTrue(); + LangfuseOtelStatus status = langfuse.getStatus(); + assertThat(status.getExportState()).isEqualTo(FAILED); + assertThat(status.getFailedExportSpanCount()).isEqualTo(1); + + // OTel 1.44.1 reports queue drain completion independently of exporter delivery. + assertThat(status.getFlushState()) + .isEqualTo(LangfuseOtelStatus.FlushState.SUCCEEDED); + } + } finally { + receiver.stop(0); + } + } + + @Test + void flushResultClassificationCoversSuccessFailureAndTimeout() { + assertThat(LangfuseOtel.awaitFlush( + CompletableResultCode.ofSuccess(), 1, TimeUnit.MILLISECONDS)) + .isEqualTo(LangfuseOtelStatus.FlushState.SUCCEEDED); + assertThat(LangfuseOtel.awaitFlush( + CompletableResultCode.ofFailure(), 1, TimeUnit.MILLISECONDS)) + .isEqualTo(LangfuseOtelStatus.FlushState.FAILED); + assertThat(LangfuseOtel.awaitFlush( + new CompletableResultCode(), 1, TimeUnit.MILLISECONDS)) + .isEqualTo(TIMED_OUT); + } + + @Test + void interruptedFlushWaitIsFailedRatherThanTimedOutAndPreservesInterrupt() throws Exception { + AtomicReference state = new AtomicReference<>(); + AtomicReference interrupted = new AtomicReference<>(); + Thread thread = new Thread(() -> { + Thread.currentThread().interrupt(); + state.set(LangfuseOtel.awaitFlush( + new CompletableResultCode(), 1, TimeUnit.SECONDS)); + interrupted.set(Thread.currentThread().isInterrupted()); + }); + + thread.start(); + thread.join(5_000); + + assertThat(thread.isAlive()).isFalse(); + assertThat(state.get()).isEqualTo(LangfuseOtelStatus.FlushState.FAILED); + assertThat(interrupted.get()).isTrue(); + } + + @Test + void olderConcurrentFlushCannotOverwriteNewerOutcome() { + LangfuseOtelRuntime runtime = LangfuseOtelRuntime.unmonitored( + true, LangfuseOtelStatus.NoopReason.NONE); + long first = runtime.beginFlush(); + long second = runtime.beginFlush(); + + runtime.completeFlush(second, LangfuseOtelStatus.FlushState.SUCCEEDED); + assertThat(runtime.snapshot(OWNED, false).getFlushState()) + .isEqualTo(LangfuseOtelStatus.FlushState.IN_PROGRESS); + + runtime.completeFlush(first, LangfuseOtelStatus.FlushState.FAILED); + LangfuseOtelStatus status = runtime.snapshot(OWNED, false); + assertThat(status.getFlushState()).isEqualTo(LangfuseOtelStatus.FlushState.SUCCEEDED); + assertThat(status.getFailedFlushCount()).isEqualTo(1); + } + + @Test + void successfulExportRecoversCurrentFailureAndDropStateWithoutResettingCounters() { + LangfuseOtelRuntime runtime = LangfuseOtelRuntime.managed(); + runtime.recordExportCompleted(false, 3); + runtime.recordQueueDropped(2); + + LangfuseOtelStatus failed = runtime.snapshot(OWNED, false); + assertThat(failed.getExportState()).isEqualTo(FAILED); + assertThat(failed.getFailedExportSpanCount()).isEqualTo(3); + assertThat(failed.getQueueDroppedSpanCount()).isEqualTo(2); + assertThat(failed.hasQueueDropsSinceLastSuccessfulExport()).isTrue(); + + runtime.recordExportCompleted(true, 1); + + LangfuseOtelStatus recovered = runtime.snapshot(OWNED, false); + assertThat(recovered.getExportState()).isEqualTo(SUCCEEDED); + assertThat(recovered.getFailedExportSpanCount()).isEqualTo(3); + assertThat(recovered.getQueueDroppedSpanCount()).isEqualTo(2); + assertThat(recovered.hasQueueDropsSinceLastSuccessfulExport()).isFalse(); + } + + private static LangfuseOtel standalone(HttpServer receiver) { + return LangfuseOtel.builder() + .publicKey("pk-status") + .secretKey("sk-status") + .host("http://127.0.0.1:" + receiver.getAddress().getPort()) + .allowInsecureHttpForDevelopment(true) + .build(); + } + + private static HttpServer startReceiver(int responseStatus, CountDownLatch received) throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + try { + exchange.getRequestBody().readAllBytes(); + exchange.getResponseHeaders().set("Content-Type", "application/x-protobuf"); + exchange.sendResponseHeaders(responseStatus, 0); + exchange.getResponseBody().close(); + } finally { + exchange.close(); + received.countDown(); + } + }); + server.start(); + return server; + } +} diff --git a/langfuse-otel-core/src/test/java/io/github/chomingi/langfuse/otel/LangfuseOtelTransportSecurityTest.java b/langfuse-otel-core/src/test/java/io/github/chomingi/langfuse/otel/LangfuseOtelTransportSecurityTest.java index 8ea3c1a..b65f822 100644 --- a/langfuse-otel-core/src/test/java/io/github/chomingi/langfuse/otel/LangfuseOtelTransportSecurityTest.java +++ b/langfuse-otel-core/src/test/java/io/github/chomingi/langfuse/otel/LangfuseOtelTransportSecurityTest.java @@ -2,16 +2,24 @@ import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; +import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest; +import io.opentelemetry.proto.common.v1.AnyValue; +import io.opentelemetry.proto.common.v1.KeyValue; +import io.opentelemetry.proto.trace.v1.ResourceSpans; +import io.opentelemetry.proto.trace.v1.Span; import org.junit.jupiter.api.Test; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Base64; import java.util.List; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -58,6 +66,89 @@ void standaloneExporterSendsExpectedOtlpContractToExplicitDevelopmentHttpEndpoin } } + @Test + void standaloneExporterPreservesMultiSpanHierarchyAndRepresentativeAttributes() throws Exception { + List captured = new CopyOnWriteArrayList<>(); + CountDownLatch received = new CountDownLatch(1); + HttpServer receiver = startServer(exchange -> { + try { + captured.add(CapturedRequest.from(exchange)); + sendEmptyProtobufResponse(exchange, 200); + } finally { + received.countDown(); + } + }); + + try { + try (LangfuseOtel langfuse = standaloneBuilder(loopbackHost(receiver)) + .allowInsecureHttpForDevelopment(true) + .build()) { + langfuse.trace("contract-trace", trace -> { + trace.userId("user-42") + .sessionId("session-7") + .tags("contract", "0.2"); + trace.span("retrieve-context", span -> { + span.input("question") + .metadata("source", "catalog"); + span.generation("answer", generation -> generation + .model("gpt-4o-mini") + .totalTokens(19)); + }); + }); + langfuse.flush(); + } + + assertThat(received.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(captured).isNotEmpty(); + + List exports = decodeExports(captured); + List resourceSpans = resourceSpans(exports); + assertThat(resourceSpans) + .extracting(group -> stringAttribute(group.getResource().getAttributesList(), "service.name")) + .containsOnly("transport-contract-test"); + + List spans = spans(resourceSpans); + assertThat(spans) + .extracting(Span::getName) + .containsExactlyInAnyOrder("contract-trace", "retrieve-context", "answer"); + + Span trace = spanNamed(spans, "contract-trace"); + Span retrieval = spanNamed(spans, "retrieve-context"); + Span generation = spanNamed(spans, "answer"); + + assertThat(trace.getTraceId()).hasSize(16); + assertThat(retrieval.getTraceId()).isEqualTo(trace.getTraceId()); + assertThat(generation.getTraceId()).isEqualTo(trace.getTraceId()); + assertThat(List.of(trace.getSpanId(), retrieval.getSpanId(), generation.getSpanId())) + .allSatisfy(spanId -> assertThat(spanId).hasSize(8)) + .doesNotHaveDuplicates(); + assertThat(trace.getParentSpanId()).isEmpty(); + assertThat(retrieval.getParentSpanId()).isEqualTo(trace.getSpanId()); + assertThat(generation.getParentSpanId()).isEqualTo(retrieval.getSpanId()); + assertThat(trace.getKind()).isEqualTo(Span.SpanKind.SPAN_KIND_INTERNAL); + assertThat(retrieval.getKind()).isEqualTo(Span.SpanKind.SPAN_KIND_INTERNAL); + assertThat(generation.getKind()).isEqualTo(Span.SpanKind.SPAN_KIND_CLIENT); + + assertThat(stringAttribute(trace, LangfuseAttributes.TRACE_NAME)).isEqualTo("contract-trace"); + assertThat(stringAttribute(trace, LangfuseAttributes.TRACE_USER_ID)).isEqualTo("user-42"); + assertThat(stringAttribute(trace, LangfuseAttributes.TRACE_SESSION_ID)).isEqualTo("session-7"); + assertThat(stringArrayAttribute(trace, LangfuseAttributes.TRACE_TAGS)) + .containsExactly("contract", "0.2"); + + assertThat(stringAttribute(retrieval, LangfuseAttributes.OBSERVATION_INPUT)) + .isEqualTo("question"); + assertThat(stringAttribute(retrieval, LangfuseAttributes.OBSERVATION_METADATA + ".source")) + .isEqualTo("catalog"); + + assertThat(stringAttribute(generation, LangfuseAttributes.GEN_AI_OPERATION_NAME)).isEqualTo("chat"); + assertThat(stringAttribute(generation, LangfuseAttributes.GEN_AI_REQUEST_MODEL)) + .isEqualTo("gpt-4o-mini"); + assertThat(longAttribute(generation, LangfuseAttributes.GEN_AI_USAGE_TOTAL_TOKENS)).isEqualTo(19); + } finally { + receiver.stop(0); + } + } + @Test void insecureHttpRequiresExplicitDevelopmentOptIn() { assertThatThrownBy(() -> standaloneBuilder("http://127.0.0.1:4318") @@ -106,13 +197,11 @@ void standaloneHostRejectsUnsafeOrAmbiguousUris() { } @Test - void crossOriginRedirectDoesNotForwardBasicAuthorization() throws Exception { + void crossOriginRedirectIsNotFollowed() throws Exception { AtomicReference redirectedRequest = new AtomicReference<>(); - CountDownLatch redirectReceived = new CountDownLatch(1); HttpServer target = startServer(exchange -> { redirectedRequest.set(CapturedRequest.from(exchange)); sendEmptyProtobufResponse(exchange, 200); - redirectReceived.countDown(); }); AtomicReference sourceAuthorization = new AtomicReference<>(); @@ -132,10 +221,8 @@ void crossOriginRedirectDoesNotForwardBasicAuthorization() throws Exception { langfuse.flush(); } - assertThat(redirectReceived.await(5, TimeUnit.SECONDS)).isTrue(); assertThat(sourceAuthorization.get()).isEqualTo(EXPECTED_AUTHORIZATION); - assertThat(redirectedRequest.get()).isNotNull(); - assertThat(redirectedRequest.get().authorization).isNull(); + assertThat(redirectedRequest.get()).isNull(); } finally { source.stop(0); target.stop(0); @@ -182,6 +269,75 @@ private static boolean containsUtf8(byte[] payload, String expected) { return false; } + private static List decodeExports(List requests) + throws IOException { + List exports = new ArrayList<>(); + for (CapturedRequest request : requests) { + exports.add(ExportTraceServiceRequest.parseFrom(request.body)); + } + return exports; + } + + private static List resourceSpans(List exports) { + return exports.stream() + .flatMap(export -> export.getResourceSpansList().stream()) + .collect(Collectors.toList()); + } + + private static List spans(List resourceSpans) { + return resourceSpans.stream() + .flatMap(group -> group.getScopeSpansList().stream()) + .flatMap(scope -> scope.getSpansList().stream()) + .collect(Collectors.toList()); + } + + private static Span spanNamed(List spans, String name) { + List matches = spans.stream() + .filter(span -> span.getName().equals(name)) + .collect(Collectors.toList()); + assertThat(matches).as("span named %s", name).hasSize(1); + return matches.get(0); + } + + private static String stringAttribute(Span span, String key) { + return stringAttribute(span.getAttributesList(), key); + } + + private static String stringAttribute(List attributes, String key) { + AnyValue value = attribute(attributes, key); + assertThat(value.getValueCase()).as("attribute %s type", key) + .isEqualTo(AnyValue.ValueCase.STRING_VALUE); + return value.getStringValue(); + } + + private static long longAttribute(Span span, String key) { + AnyValue value = attribute(span.getAttributesList(), key); + assertThat(value.getValueCase()).as("attribute %s type", key) + .isEqualTo(AnyValue.ValueCase.INT_VALUE); + return value.getIntValue(); + } + + private static List stringArrayAttribute(Span span, String key) { + AnyValue value = attribute(span.getAttributesList(), key); + assertThat(value.getValueCase()).as("attribute %s type", key) + .isEqualTo(AnyValue.ValueCase.ARRAY_VALUE); + return value.getArrayValue().getValuesList().stream() + .map(item -> { + assertThat(item.getValueCase()).as("attribute %s item type", key) + .isEqualTo(AnyValue.ValueCase.STRING_VALUE); + return item.getStringValue(); + }) + .collect(Collectors.toList()); + } + + private static AnyValue attribute(List attributes, String key) { + return attributes.stream() + .filter(attribute -> attribute.getKey().equals(key)) + .map(KeyValue::getValue) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing OTLP attribute: " + key)); + } + @FunctionalInterface private interface ExchangeHandler { void handle(HttpExchange exchange) throws IOException; diff --git a/langfuse-otel-core/src/test/java/io/github/chomingi/langfuse/otel/PackagedJarManifestIT.java b/langfuse-otel-core/src/test/java/io/github/chomingi/langfuse/otel/PackagedJarManifestIT.java new file mode 100644 index 0000000..895c3d2 --- /dev/null +++ b/langfuse-otel-core/src/test/java/io/github/chomingi/langfuse/otel/PackagedJarManifestIT.java @@ -0,0 +1,24 @@ +package io.github.chomingi.langfuse.otel; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.jar.Attributes; +import java.util.jar.JarFile; + +import static org.assertj.core.api.Assertions.assertThat; + +class PackagedJarManifestIT { + + @Test + void packagedJarDeclaresItsImplementationVersion() throws IOException { + String packagedJar = System.getProperty("packagedJar"); + String expectedVersion = System.getProperty("expectedImplementationVersion"); + + try (JarFile jarFile = new JarFile(packagedJar)) { + assertThat(jarFile.getManifest().getMainAttributes() + .getValue(Attributes.Name.IMPLEMENTATION_VERSION)) + .isEqualTo(expectedVersion); + } + } +} diff --git a/langfuse-otel-core/src/test/java/io/github/chomingi/langfuse/otel/SpanGuardScopeTest.java b/langfuse-otel-core/src/test/java/io/github/chomingi/langfuse/otel/SpanGuardScopeTest.java index 94da088..d98b29d 100644 --- a/langfuse-otel-core/src/test/java/io/github/chomingi/langfuse/otel/SpanGuardScopeTest.java +++ b/langfuse-otel-core/src/test/java/io/github/chomingi/langfuse/otel/SpanGuardScopeTest.java @@ -1,6 +1,7 @@ package io.github.chomingi.langfuse.otel; import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; import io.opentelemetry.api.trace.Tracer; import io.opentelemetry.context.Scope; import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension; @@ -8,10 +9,14 @@ import org.junit.jupiter.api.extension.RegisterExtension; import java.lang.ref.Cleaner; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class SpanGuardScopeTest { @@ -44,34 +49,91 @@ void wrapperCloseRestoresParentContextAfterRawSpanWasEndedFirst() { } @Test - void cleanerPathEndsSpanWithoutClosingThreadAffineScope() { + void cleanerPathEndsSpanWithoutOwningThreadAffineScope() { Tracer tracer = otel.getOpenTelemetry().getTracer("cleaner-thread-safety-test"); Span span = tracer.spanBuilder("abandoned-generation").startSpan(); - AtomicInteger scopeCloseCalls = new AtomicInteger(); - Scope threadAffineScope = scopeCloseCalls::incrementAndGet; Object owner = new Object(); Cleaner.Cleanable cleanable = SpanGuard.register( - owner, span, threadAffineScope, "abandoned-generation", new AtomicBoolean(false)); + owner, span, "abandoned-generation", new AtomicBoolean(false)); cleanable.clean(); - assertThat(scopeCloseCalls).hasValue(0); assertThat(span.isRecording()).isFalse(); } @Test - void explicitClosePathStillClosesScopeBeforeEndingSpan() { - Tracer tracer = otel.getOpenTelemetry().getTracer("explicit-close-test"); - Span span = tracer.spanBuilder("explicit-generation").startSpan(); - AtomicInteger scopeCloseCalls = new AtomicInteger(); - Scope scope = scopeCloseCalls::incrementAndGet; - AtomicBoolean closed = new AtomicBoolean(true); - Object owner = new Object(); + void crossThreadEndFailsWithoutChangingEitherThreadContext() throws Exception { + Tracer tracer = otel.getOpenTelemetry().getTracer("cross-thread-close-test"); + Span parent = tracer.spanBuilder("parent").startSpan(); + ExecutorService executor = Executors.newSingleThreadExecutor(); - Cleaner.Cleanable cleanable = SpanGuard.register(owner, span, scope, "explicit-generation", closed); - cleanable.clean(); + try (Scope parentScope = parent.makeCurrent()) { + LangfuseGeneration generation = new LangfuseGeneration(tracer, "generation"); + AtomicReference workerContextBefore = new AtomicReference<>(); + AtomicReference workerContextAfter = new AtomicReference<>(); + + try { + Future result = executor.submit(() -> { + workerContextBefore.set(Span.current().getSpanContext()); + try { + generation.end(); + return null; + } catch (Throwable failure) { + return failure; + } finally { + workerContextAfter.set(Span.current().getSpanContext()); + } + }); + + assertThat(result.get()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Langfuse spans must be closed on the thread where they were created"); + assertThat(workerContextBefore).hasValue(SpanContext.getInvalid()); + assertThat(workerContextAfter).hasValue(SpanContext.getInvalid()); + assertThat(generation.getSpan().isRecording()).isTrue(); + assertThat(Span.current().getSpanContext()) + .isEqualTo(generation.getSpan().getSpanContext()); + } finally { + generation.close(); + } + + assertThat(Span.current().getSpanContext()).isEqualTo(parent.getSpanContext()); + } finally { + executor.shutdownNow(); + parent.end(); + } - assertThat(scopeCloseCalls).hasValue(1); - assertThat(span.isRecording()).isFalse(); + assertThat(otel.getSpans()) + .filteredOn(span -> span.getName().equals("generation")) + .hasSize(1); + } + + @Test + void outOfOrderCloseFailsAndCanBeRetriedInLifoOrder() { + Tracer tracer = otel.getOpenTelemetry().getTracer("lifo-close-test"); + LangfuseSpan parent = new LangfuseSpan(tracer, "parent"); + LangfuseGeneration child = new LangfuseGeneration(tracer, "child"); + + try { + assertThatThrownBy(parent::close) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Langfuse spans must be closed in reverse creation order"); + assertThat(parent.getSpan().isRecording()).isTrue(); + assertThat(child.getSpan().isRecording()).isTrue(); + assertThat(Span.current().getSpanContext()) + .isEqualTo(child.getSpan().getSpanContext()); + + child.close(); + assertThat(Span.current().getSpanContext()) + .isEqualTo(parent.getSpan().getSpanContext()); + } finally { + child.close(); + parent.close(); + } + + assertThat(Span.current().getSpanContext()).isEqualTo(SpanContext.getInvalid()); + assertThat(otel.getSpans()) + .filteredOn(span -> span.getName().equals("parent") || span.getName().equals("child")) + .hasSize(2); } } diff --git a/langfuse-otel-spring-boot-starter/pom.xml b/langfuse-otel-spring-boot-starter/pom.xml index 5b60409..ed70d78 100644 --- a/langfuse-otel-spring-boot-starter/pom.xml +++ b/langfuse-otel-spring-boot-starter/pom.xml @@ -13,6 +13,11 @@ langfuse-otel-spring-boot-starter Spring Boot Starter for Langfuse OpenTelemetry integration + + 0.785 + 0.640 + + @@ -35,34 +40,43 @@ org.springframework.boot spring-boot-starter - ${spring-boot.version} org.springframework.boot spring-boot-autoconfigure - ${spring-boot.version} org.springframework.boot spring-boot-configuration-processor - ${spring-boot.version} true org.springframework.boot spring-boot-starter-aop - ${spring-boot.version} org.springframework.boot spring-boot-starter-web - ${spring-boot.version} true org.springframework.boot spring-boot-starter-webflux - ${spring-boot.version} + true + + + org.springframework.boot + spring-boot-actuator + true + + + org.springframework.boot + spring-boot-actuator-autoconfigure + true + + + io.micrometer + micrometer-core true @@ -82,13 +96,11 @@ org.springframework.boot spring-boot-starter-test - ${spring-boot.version} test io.projectreactor reactor-test - 3.7.5 test @@ -96,5 +108,11 @@ opentelemetry-sdk-testing test + + dev.langchain4j + langchain4j-open-ai + ${langchain4j.version} + test + diff --git a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangChain4jAutoConfiguration.java b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangChain4jAutoConfiguration.java index 0b8882d..39b579c 100644 --- a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangChain4jAutoConfiguration.java +++ b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangChain4jAutoConfiguration.java @@ -9,23 +9,44 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Role; +/** + * Adds Langfuse tracing decorators to supported LangChain4j model beans. + */ @AutoConfiguration(after = LangfuseOtelAutoConfiguration.class) @ConditionalOnClass(name = "dev.langchain4j.model.chat.ChatModel") @ConditionalOnBean(LangfuseOtel.class) public class LangChain4jAutoConfiguration { + /** + * Creates infrastructure that decorates LangChain4j chat models. + * + * @param langfuseOtelProvider provider for the tracing integration + * @return the chat model post-processor + */ @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) public static LangChain4jChatModelBeanPostProcessor langChain4jChatModelBeanPostProcessor(ObjectProvider langfuseOtelProvider) { return new LangChain4jChatModelBeanPostProcessor(langfuseOtelProvider); } + /** + * Creates infrastructure that decorates LangChain4j embedding models. + * + * @param langfuseOtelProvider provider for the tracing integration + * @return the embedding model post-processor + */ @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) public static LangChain4jEmbeddingModelBeanPostProcessor langChain4jEmbeddingModelBeanPostProcessor(ObjectProvider langfuseOtelProvider) { return new LangChain4jEmbeddingModelBeanPostProcessor(langfuseOtelProvider); } + /** + * Creates infrastructure that decorates LangChain4j image models. + * + * @param langfuseOtelProvider provider for the tracing integration + * @return the image model post-processor + */ @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) public static LangChain4jImageModelBeanPostProcessor langChain4jImageModelBeanPostProcessor(ObjectProvider langfuseOtelProvider) { diff --git a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseContextFilter.java b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseContextFilter.java index 6f1259d..91581f3 100644 --- a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseContextFilter.java +++ b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseContextFilter.java @@ -15,11 +15,19 @@ import java.io.IOException; import java.security.Principal; +/** + * Populates Langfuse trace context from servlet authentication and session state. + */ @Order(Ordered.LOWEST_PRECEDENCE - 100) public class LangfuseContextFilter extends OncePerRequestFilter { private final LangfuseOtelProperties properties; + /** + * Creates a filter using the configured request-context capture policy. + * + * @param properties starter configuration + */ public LangfuseContextFilter(LangfuseOtelProperties properties) { this.properties = properties; } diff --git a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelAutoConfiguration.java b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelAutoConfiguration.java index bddde03..e5024b2 100644 --- a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelAutoConfiguration.java +++ b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelAutoConfiguration.java @@ -14,8 +14,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -26,6 +26,9 @@ import static io.github.chomingi.langfuse.otel.spring.LangfuseOtelProperties.OpenTelemetryMode.AUTO; import static io.github.chomingi.langfuse.otel.spring.LangfuseOtelProperties.OpenTelemetryMode.EXTERNAL; +/** + * Configures the Langfuse client and optional Spring integration components. + */ @AutoConfiguration @EnableConfigurationProperties(LangfuseOtelProperties.class) @ConditionalOnProperty(prefix = "langfuse", name = "enabled", havingValue = "true", matchIfMissing = true) @@ -33,19 +36,56 @@ public class LangfuseOtelAutoConfiguration { private static final Logger log = LoggerFactory.getLogger(LangfuseOtelAutoConfiguration.class); + /** + * Creates a Langfuse client from application properties and available extension beans. + * + * @param properties starter configuration + * @param openTelemetryProvider application OpenTelemetry beans + * @param contentRedactorProvider content redactor beans + * @param exceptionRedactorProvider exception redactor beans + * @return the configured tracing integration + */ @Bean(destroyMethod = "close") @ConditionalOnMissingBean public LangfuseOtel langfuseOtel(LangfuseOtelProperties properties, ObjectProvider openTelemetryProvider, ObjectProvider contentRedactorProvider, ObjectProvider exceptionRedactorProvider) { + List contentRedactors = contentRedactorProvider.orderedStream() + .collect(Collectors.toList()); + List exceptionRedactors = exceptionRedactorProvider.orderedStream() + .collect(Collectors.toList()); + List externalOpenTelemetryBeans = openTelemetryProvider.orderedStream() + .collect(Collectors.toList()); + OpenTelemetry externalOpenTelemetry = openTelemetryProvider.getIfUnique(); + + return createLangfuseOtel(properties, contentRedactors, exceptionRedactors, + externalOpenTelemetryBeans, externalOpenTelemetry); + } + + /** + * Compatibility bridge for callers that directly invoked the 0.1.x auto-configuration factory. + * This direct call does not discover Spring beans; {@code AUTO} mode therefore uses the + * standalone path. + * + * @param properties standalone configuration properties + * @return the configured tracing integration + * @deprecated Prefer Spring auto-configuration or {@link LangfuseOtel#builder()}. + */ + @Deprecated(since = "0.2.0", forRemoval = false) + public LangfuseOtel langfuseOtel(LangfuseOtelProperties properties) { + return createLangfuseOtel(properties, List.of(), List.of(), List.of(), null); + } + + private LangfuseOtel createLangfuseOtel(LangfuseOtelProperties properties, + List contentRedactors, + List exceptionRedactors, + List externalOpenTelemetryBeans, + OpenTelemetry externalOpenTelemetry) { ContentCapturePolicy.Builder capturePolicyBuilder = ContentCapturePolicy.builder() .captureInput(properties.getContent().isCaptureInput()) .captureOutput(properties.getContent().isCaptureOutput()) .maxLength(properties.getContent().getMaxLength()); - - List contentRedactors = contentRedactorProvider.orderedStream() - .collect(Collectors.toList()); if (contentRedactors.size() == 1) { capturePolicyBuilder.redactor(contentRedactors.get(0)); } else if (contentRedactors.size() > 1) { @@ -57,8 +97,6 @@ public LangfuseOtel langfuseOtel(LangfuseOtelProperties properties, .captureMessage(properties.getException().isCaptureMessage()) .captureStackTrace(properties.getException().isCaptureStackTrace()) .maxLength(properties.getException().getMaxLength()); - List exceptionRedactors = exceptionRedactorProvider.orderedStream() - .collect(Collectors.toList()); if (exceptionRedactors.size() == 1) { exceptionPolicyBuilder.redactor(exceptionRedactors.get(0)); } else if (exceptionRedactors.size() > 1) { @@ -66,9 +104,6 @@ public LangfuseOtel langfuseOtel(LangfuseOtelProperties properties, exceptionPolicyBuilder.redactor((type, content) -> null); } - List externalOpenTelemetryBeans = openTelemetryProvider.orderedStream() - .collect(Collectors.toList()); - OpenTelemetry externalOpenTelemetry = openTelemetryProvider.getIfUnique(); LangfuseOtelProperties.OpenTelemetryMode otelMode = properties.getOtelMode(); if (otelMode == null) { throw new IllegalStateException("langfuse.otel-mode must not be null"); @@ -114,6 +149,12 @@ public LangfuseOtel langfuseOtel(LangfuseOtelProperties properties, .build(); } + /** + * Creates the aspect that traces methods annotated with {@code @ObserveGeneration}. + * + * @param langfuseOtel tracing integration + * @return the generation observation aspect + */ @Bean @ConditionalOnMissingBean @ConditionalOnBean(LangfuseOtel.class) @@ -121,17 +162,30 @@ public ObserveGenerationAspect observeGenerationAspect(LangfuseOtel langfuseOtel return new ObserveGenerationAspect(langfuseOtel); } + /** + * Creates the servlet filter that propagates request-derived Langfuse context. + * + * @param properties starter configuration + * @return the servlet context filter + */ @Bean @ConditionalOnMissingBean @ConditionalOnClass(name = "jakarta.servlet.Filter") + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) public LangfuseContextFilter langfuseContextFilter(LangfuseOtelProperties properties) { return new LangfuseContextFilter(properties); } + /** + * Creates the WebFlux filter that propagates request-derived Langfuse context. + * + * @param properties starter configuration + * @return the reactive context filter + */ @Bean @ConditionalOnMissingBean @ConditionalOnClass(name = "org.springframework.web.server.WebFilter") - @ConditionalOnMissingClass("jakarta.servlet.Filter") + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) public LangfuseReactiveContextFilter langfuseReactiveContextFilter(LangfuseOtelProperties properties) { return new LangfuseReactiveContextFilter(properties); } diff --git a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelHealthIndicator.java b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelHealthIndicator.java new file mode 100644 index 0000000..3170d29 --- /dev/null +++ b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelHealthIndicator.java @@ -0,0 +1,68 @@ +package io.github.chomingi.langfuse.otel.spring; + +import io.github.chomingi.langfuse.otel.LangfuseOtel; +import io.github.chomingi.langfuse.otel.LangfuseOtelStatus; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; + +import java.util.Locale; + +final class LangfuseOtelHealthIndicator implements HealthIndicator { + + private final LangfuseOtel langfuseOtel; + + LangfuseOtelHealthIndicator(LangfuseOtel langfuseOtel) { + this.langfuseOtel = langfuseOtel; + } + + @Override + public Health health() { + LangfuseOtelStatus status = langfuseOtel.getStatus(); + Health.Builder builder = healthBuilder(status) + .withDetail("ownership", lower(status.getOpenTelemetryOwnership())) + .withDetail("noopFallback", status.isNoopFallback()) + .withDetail("noopReason", lower(status.getNoopReason())); + + if (!status.isOperationalSignalsAvailable()) { + return builder + .withDetail("operationalSignals", "not_managed") + .withDetail("exportState", "not_managed") + .withDetail("queue", "not_managed") + .withDetail("flushState", "not_managed") + .build(); + } + + return builder + .withDetail("operationalSignals", "managed") + .withDetail("exportState", lower(status.getExportState())) + .withDetail("failedExportSpans", status.getFailedExportSpanCount()) + .withDetail("queueDroppedSpans", status.getQueueDroppedSpanCount()) + .withDetail("queueDropsSinceLastSuccessfulExport", + status.hasQueueDropsSinceLastSuccessfulExport()) + .withDetail("flushState", lower(status.getFlushState())) + .withDetail("failedFlushes", status.getFailedFlushCount()) + .withDetail("timedOutFlushes", status.getTimedOutFlushCount()) + .build(); + } + + private static Health.Builder healthBuilder(LangfuseOtelStatus status) { + if (status.isNoopFallback()) { + return Health.outOfService(); + } + if (status.getOpenTelemetryOwnership() == LangfuseOtel.OpenTelemetryOwnership.EXTERNAL + || !status.isOperationalSignalsAvailable()) { + return Health.unknown(); + } + if (status.getExportState() == LangfuseOtelStatus.ExportState.FAILED + || status.hasQueueDropsSinceLastSuccessfulExport() + || status.getFlushState() == LangfuseOtelStatus.FlushState.FAILED + || status.getFlushState() == LangfuseOtelStatus.FlushState.TIMED_OUT) { + return Health.down(); + } + return Health.up(); + } + + private static String lower(Enum value) { + return value.name().toLowerCase(Locale.ROOT); + } +} diff --git a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelMeterBinder.java b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelMeterBinder.java new file mode 100644 index 0000000..9dc7d04 --- /dev/null +++ b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelMeterBinder.java @@ -0,0 +1,73 @@ +package io.github.chomingi.langfuse.otel.spring; + +import io.github.chomingi.langfuse.otel.LangfuseOtel; +import io.github.chomingi.langfuse.otel.LangfuseOtelStatus; +import io.micrometer.core.instrument.FunctionCounter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.binder.MeterBinder; + +import java.util.Locale; + +final class LangfuseOtelMeterBinder implements MeterBinder { + + private final LangfuseOtel langfuseOtel; + + LangfuseOtelMeterBinder(LangfuseOtel langfuseOtel) { + this.langfuseOtel = langfuseOtel; + } + + @Override + public void bindTo(MeterRegistry registry) { + LangfuseOtelStatus initial = langfuseOtel.getStatus(); + Tags runtimeTags = Tags.of( + "ownership", lower(initial.getOpenTelemetryOwnership()), + "fallback_reason", lower(initial.getNoopReason())); + + Gauge.builder("langfuse.otel.noop.fallback", langfuseOtel, + source -> source.getStatus().isNoopFallback() ? 1.0 : 0.0) + .description("Whether fail-safe construction produced the Langfuse OTel no-op fallback") + .tags(runtimeTags) + .register(registry); + + if (!initial.isOperationalSignalsAvailable()) { + return; + } + + FunctionCounter.builder("langfuse.otel.export.failed.spans", langfuseOtel, + source -> source.getStatus().getFailedExportSpanCount()) + .description("Spans contained in standalone exporter calls reported as failed") + .baseUnit("spans") + .register(registry); + FunctionCounter.builder("langfuse.otel.queue.dropped.spans", langfuseOtel, + source -> source.getStatus().getQueueDroppedSpanCount()) + .description("Spans dropped because the standalone export queue was full") + .baseUnit("spans") + .register(registry); + FunctionCounter.builder("langfuse.otel.flush.failures", langfuseOtel, + source -> source.getStatus().getFailedFlushCount()) + .description("Owned SDK flush waits that failed or were interrupted") + .baseUnit("operations") + .register(registry); + FunctionCounter.builder("langfuse.otel.flush.timeouts", langfuseOtel, + source -> source.getStatus().getTimedOutFlushCount()) + .description("Owned SDK flush waits that timed out") + .baseUnit("operations") + .register(registry); + for (LangfuseOtelStatus.FlushState state : LangfuseOtelStatus.FlushState.values()) { + if (state == LangfuseOtelStatus.FlushState.NOT_MANAGED) { + continue; + } + Gauge.builder("langfuse.otel.flush.state", langfuseOtel, + source -> source.getStatus().getFlushState() == state ? 1.0 : 0.0) + .description("Current owned SDK flush state") + .tag("state", lower(state)) + .register(registry); + } + } + + private static String lower(Enum value) { + return value.name().toLowerCase(Locale.ROOT); + } +} diff --git a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelObservabilityAutoConfiguration.java b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelObservabilityAutoConfiguration.java new file mode 100644 index 0000000..29a0832 --- /dev/null +++ b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelObservabilityAutoConfiguration.java @@ -0,0 +1,42 @@ +package io.github.chomingi.langfuse.otel.spring; + +import io.github.chomingi.langfuse.otel.LangfuseOtel; +import io.micrometer.core.instrument.binder.MeterBinder; +import org.springframework.boot.actuate.autoconfigure.health.ConditionalOnEnabledHealthIndicator; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Adds optional Actuator and Micrometer signals for the Langfuse OTel runtime. */ +@AutoConfiguration(after = LangfuseOtelAutoConfiguration.class) +public class LangfuseOtelObservabilityAutoConfiguration { + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass({HealthIndicator.class, ConditionalOnEnabledHealthIndicator.class}) + @ConditionalOnBean(LangfuseOtel.class) + static class HealthConfiguration { + + @Bean + @ConditionalOnEnabledHealthIndicator("langfuse") + @ConditionalOnMissingBean(name = "langfuseHealthIndicator") + HealthIndicator langfuseHealthIndicator(LangfuseOtel langfuseOtel) { + return new LangfuseOtelHealthIndicator(langfuseOtel); + } + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(MeterBinder.class) + @ConditionalOnBean(LangfuseOtel.class) + static class MetricsConfiguration { + + @Bean + @ConditionalOnMissingBean(name = "langfuseOtelMeterBinder") + MeterBinder langfuseOtelMeterBinder(LangfuseOtel langfuseOtel) { + return new LangfuseOtelMeterBinder(langfuseOtel); + } + } +} diff --git a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelProperties.java b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelProperties.java index 333d69b..14a1e64 100644 --- a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelProperties.java +++ b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelProperties.java @@ -4,6 +4,9 @@ import io.github.chomingi.langfuse.otel.ExceptionCapturePolicy; import org.springframework.boot.context.properties.ConfigurationProperties; +/** + * Configuration properties for the Langfuse OpenTelemetry starter. + */ @ConfigurationProperties(prefix = "langfuse") public class LangfuseOtelProperties { @@ -20,41 +23,66 @@ public class LangfuseOtelProperties { private final ExceptionDetails exception = new ExceptionDetails(); private final RequestContext context = new RequestContext(); + /** @return the Langfuse public key */ public String getPublicKey() { return publicKey; } + /** @param publicKey Langfuse public key */ public void setPublicKey(String publicKey) { this.publicKey = publicKey; } + /** @return the Langfuse secret key */ public String getSecretKey() { return secretKey; } + /** @param secretKey Langfuse secret key */ public void setSecretKey(String secretKey) { this.secretKey = secretKey; } + /** @return the Langfuse base URL to which the OTLP trace path is appended */ public String getHost() { return host; } + /** @param host Langfuse base URL to which the OTLP trace path is appended */ public void setHost(String host) { this.host = host; } + /** @return the service name attached to telemetry */ public String getServiceName() { return serviceName; } + /** @param serviceName service name attached to telemetry */ public void setServiceName(String serviceName) { this.serviceName = serviceName; } + /** @return the deployment environment attached to traces */ public String getEnvironment() { return environment; } + /** @param environment deployment environment attached to traces */ public void setEnvironment(String environment) { this.environment = environment; } + /** @return the application release attached to traces */ public String getRelease() { return release; } + /** @param release application release attached to traces */ public void setRelease(String release) { this.release = release; } + /** @return whether Langfuse auto-configuration is enabled */ public boolean isEnabled() { return enabled; } + /** @param enabled whether to enable Langfuse auto-configuration */ public void setEnabled(boolean enabled) { this.enabled = enabled; } + /** @return whether development-only insecure HTTP is allowed for loopback endpoints */ public boolean isAllowInsecureHttpForDevelopment() { return allowInsecureHttpForDevelopment; } + /** + * @param allowInsecureHttpForDevelopment whether to allow insecure HTTP for loopback + * development endpoints + */ public void setAllowInsecureHttpForDevelopment(boolean allowInsecureHttpForDevelopment) { this.allowInsecureHttpForDevelopment = allowInsecureHttpForDevelopment; } + /** @return the OpenTelemetry ownership mode */ public OpenTelemetryMode getOtelMode() { return otelMode; } + /** @param otelMode OpenTelemetry ownership mode */ public void setOtelMode(OpenTelemetryMode otelMode) { this.otelMode = otelMode; } + /** @return content-capture settings */ public Content getContent() { return content; } + /** @return exception-capture settings */ public ExceptionDetails getException() { return exception; } + /** @return request-context capture settings */ public RequestContext getContext() { return context; } + /** Defines whether the starter creates or reuses an OpenTelemetry instance. */ public enum OpenTelemetryMode { /** Reuse exactly one application OpenTelemetry bean, otherwise create a standalone SDK. */ AUTO, @@ -64,47 +92,66 @@ public enum OpenTelemetryMode { STANDALONE } + /** Configures prompt and response content capture. */ public static class Content { private boolean captureInput; private boolean captureOutput; private int maxLength = ContentCapturePolicy.DEFAULT_MAX_LENGTH; + /** @return whether model input is captured */ public boolean isCaptureInput() { return captureInput; } + /** @param captureInput whether to capture model input */ public void setCaptureInput(boolean captureInput) { this.captureInput = captureInput; } + /** @return whether model output is captured */ public boolean isCaptureOutput() { return captureOutput; } + /** @param captureOutput whether to capture model output */ public void setCaptureOutput(boolean captureOutput) { this.captureOutput = captureOutput; } + /** @return the positive post-redaction limit in UTF-16 code units */ public int getMaxLength() { return maxLength; } + /** @param maxLength positive post-redaction limit in UTF-16 code units */ public void setMaxLength(int maxLength) { this.maxLength = maxLength; } } + /** Configures exception detail capture. */ public static class ExceptionDetails { private boolean captureMessage; private boolean captureStackTrace; private int maxLength = ExceptionCapturePolicy.DEFAULT_MAX_LENGTH; + /** @return whether exception messages are captured */ public boolean isCaptureMessage() { return captureMessage; } + /** @param captureMessage whether to capture exception messages */ public void setCaptureMessage(boolean captureMessage) { this.captureMessage = captureMessage; } + /** @return whether exception stack traces are captured */ public boolean isCaptureStackTrace() { return captureStackTrace; } + /** @param captureStackTrace whether to capture exception stack traces */ public void setCaptureStackTrace(boolean captureStackTrace) { this.captureStackTrace = captureStackTrace; } + /** @return the positive post-redaction limit per exception detail in UTF-16 code units */ public int getMaxLength() { return maxLength; } + /** @param maxLength positive post-redaction limit per exception detail in UTF-16 code units */ public void setMaxLength(int maxLength) { this.maxLength = maxLength; } } + /** Configures request-derived Langfuse trace context. */ public static class RequestContext { private boolean captureUserId; private boolean captureSessionId; + /** @return whether {@code Principal#getName()} is captured as the user ID */ public boolean isCaptureUserId() { return captureUserId; } + /** @param captureUserId whether to capture {@code Principal#getName()} as the user ID */ public void setCaptureUserId(boolean captureUserId) { this.captureUserId = captureUserId; } + /** @return whether the HTTP session ID is captured */ public boolean isCaptureSessionId() { return captureSessionId; } + /** @param captureSessionId whether to capture the HTTP session ID */ public void setCaptureSessionId(boolean captureSessionId) { this.captureSessionId = captureSessionId; } } } diff --git a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseReactiveContextFilter.java b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseReactiveContextFilter.java index 7e10379..8589ddc 100644 --- a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseReactiveContextFilter.java +++ b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/LangfuseReactiveContextFilter.java @@ -11,11 +11,19 @@ import java.security.Principal; +/** + * Populates Reactor context with Langfuse authentication and session metadata. + */ @Order(Ordered.LOWEST_PRECEDENCE - 100) public class LangfuseReactiveContextFilter implements WebFilter { private final LangfuseOtelProperties properties; + /** + * Creates a filter using the configured request-context capture policy. + * + * @param properties starter configuration + */ public LangfuseReactiveContextFilter(LangfuseOtelProperties properties) { this.properties = properties; } diff --git a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/SpringAiAutoConfiguration.java b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/SpringAiAutoConfiguration.java index 28273ba..c8642f1 100644 --- a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/SpringAiAutoConfiguration.java +++ b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/SpringAiAutoConfiguration.java @@ -9,23 +9,44 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Role; +/** + * Adds Langfuse tracing decorators to supported Spring AI model beans. + */ @AutoConfiguration(after = LangfuseOtelAutoConfiguration.class) @ConditionalOnClass(name = "org.springframework.ai.chat.model.ChatModel") @ConditionalOnBean(LangfuseOtel.class) public class SpringAiAutoConfiguration { + /** + * Creates infrastructure that decorates Spring AI chat models. + * + * @param langfuseOtelProvider provider for the tracing integration + * @return the chat model post-processor + */ @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) public static SpringAiChatModelBeanPostProcessor springAiChatModelBeanPostProcessor(ObjectProvider langfuseOtelProvider) { return new SpringAiChatModelBeanPostProcessor(langfuseOtelProvider); } + /** + * Creates infrastructure that decorates Spring AI embedding models. + * + * @param langfuseOtelProvider provider for the tracing integration + * @return the embedding model post-processor + */ @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) public static SpringAiEmbeddingModelBeanPostProcessor springAiEmbeddingModelBeanPostProcessor(ObjectProvider langfuseOtelProvider) { return new SpringAiEmbeddingModelBeanPostProcessor(langfuseOtelProvider); } + /** + * Creates infrastructure that decorates Spring AI image models. + * + * @param langfuseOtelProvider provider for the tracing integration + * @return the image model post-processor + */ @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) public static SpringAiImageModelBeanPostProcessor springAiImageModelBeanPostProcessor(ObjectProvider langfuseOtelProvider) { diff --git a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingLangChain4jChatModel.java b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingLangChain4jChatModel.java index a563ca5..c471400 100644 --- a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingLangChain4jChatModel.java +++ b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingLangChain4jChatModel.java @@ -21,6 +21,9 @@ import java.util.List; import java.util.Set; +/** + * LangChain4j chat model decorator that records Langfuse generation spans. + */ public class TracingLangChain4jChatModel implements ChatModel { private static final Logger log = LoggerFactory.getLogger(TracingLangChain4jChatModel.class); @@ -28,6 +31,12 @@ public class TracingLangChain4jChatModel implements ChatModel { private final ChatModel delegate; private final LangfuseOtel langfuseOtel; + /** + * Creates a tracing decorator for a LangChain4j chat model. + * + * @param delegate model that performs chat requests + * @param langfuseOtel tracing integration + */ public TracingLangChain4jChatModel(ChatModel delegate, LangfuseOtel langfuseOtel) { this.delegate = delegate; this.langfuseOtel = langfuseOtel; diff --git a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingLangChain4jEmbeddingModel.java b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingLangChain4jEmbeddingModel.java index d7db817..31e9823 100644 --- a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingLangChain4jEmbeddingModel.java +++ b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingLangChain4jEmbeddingModel.java @@ -17,6 +17,9 @@ import java.util.List; import java.util.stream.Collectors; +/** + * LangChain4j embedding model decorator that records Langfuse client spans. + */ public class TracingLangChain4jEmbeddingModel implements EmbeddingModel { private static final Logger log = LoggerFactory.getLogger(TracingLangChain4jEmbeddingModel.class); @@ -24,6 +27,12 @@ public class TracingLangChain4jEmbeddingModel implements EmbeddingModel { private final EmbeddingModel delegate; private final LangfuseOtel langfuseOtel; + /** + * Creates a tracing decorator for a LangChain4j embedding model. + * + * @param delegate model that performs embedding requests + * @param langfuseOtel tracing integration + */ public TracingLangChain4jEmbeddingModel(EmbeddingModel delegate, LangfuseOtel langfuseOtel) { this.delegate = delegate; this.langfuseOtel = langfuseOtel; diff --git a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingLangChain4jImageModel.java b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingLangChain4jImageModel.java index ed7c529..4db439a 100644 --- a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingLangChain4jImageModel.java +++ b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingLangChain4jImageModel.java @@ -15,6 +15,9 @@ import java.util.List; import java.util.function.Supplier; +/** + * LangChain4j image model decorator that records Langfuse client spans. + */ public class TracingLangChain4jImageModel implements ImageModel { private static final Logger log = LoggerFactory.getLogger(TracingLangChain4jImageModel.class); @@ -22,6 +25,12 @@ public class TracingLangChain4jImageModel implements ImageModel { private final ImageModel delegate; private final LangfuseOtel langfuseOtel; + /** + * Creates a tracing decorator for a LangChain4j image model. + * + * @param delegate model that performs image requests + * @param langfuseOtel tracing integration + */ public TracingLangChain4jImageModel(ImageModel delegate, LangfuseOtel langfuseOtel) { this.delegate = delegate; this.langfuseOtel = langfuseOtel; diff --git a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingSpringAiChatModel.java b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingSpringAiChatModel.java index 833e6a2..564caf7 100644 --- a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingSpringAiChatModel.java +++ b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingSpringAiChatModel.java @@ -27,6 +27,9 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; +/** + * Spring AI chat model decorator that records Langfuse generation spans. + */ public class TracingSpringAiChatModel implements ChatModel { private static final Logger log = LoggerFactory.getLogger(TracingSpringAiChatModel.class); @@ -34,6 +37,12 @@ public class TracingSpringAiChatModel implements ChatModel { private final ChatModel delegate; private final LangfuseOtel langfuseOtel; + /** + * Creates a tracing decorator for a Spring AI chat model. + * + * @param delegate model that performs chat requests + * @param langfuseOtel tracing integration + */ public TracingSpringAiChatModel(ChatModel delegate, LangfuseOtel langfuseOtel) { this.delegate = delegate; this.langfuseOtel = langfuseOtel; diff --git a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingSpringAiEmbeddingModel.java b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingSpringAiEmbeddingModel.java index 57f9858..32626f2 100644 --- a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingSpringAiEmbeddingModel.java +++ b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingSpringAiEmbeddingModel.java @@ -18,8 +18,12 @@ import org.springframework.ai.chat.metadata.Usage; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; +/** + * Spring AI embedding model decorator that records Langfuse client spans. + */ public class TracingSpringAiEmbeddingModel implements EmbeddingModel { private static final Logger log = LoggerFactory.getLogger(TracingSpringAiEmbeddingModel.class); @@ -27,6 +31,12 @@ public class TracingSpringAiEmbeddingModel implements EmbeddingModel { private final EmbeddingModel delegate; private final LangfuseOtel langfuseOtel; + /** + * Creates a tracing decorator for a Spring AI embedding model. + * + * @param delegate model that performs embedding requests + * @param langfuseOtel tracing integration + */ public TracingSpringAiEmbeddingModel(EmbeddingModel delegate, LangfuseOtel langfuseOtel) { this.delegate = delegate; this.langfuseOtel = langfuseOtel; @@ -87,15 +97,19 @@ public float[] embed(Document document) { InstrumentationFailureSupport.endQuietly(createdSpan); InstrumentationFailureSupport.rethrowIfFatal(failure); log.debug("Langfuse document embedding instrumentation setup failed, proceeding without tracing", failure); - return delegate.embed(document); + return Objects.requireNonNull( + delegate.embed(document), + "Spring AI EmbeddingModel delegate returned null from embed(Document)"); } Span span = createdSpan; try { - float[] embedding = SpanScopeSupport.call(span, () -> delegate.embed(document)); + float[] embedding = Objects.requireNonNull( + SpanScopeSupport.call(span, () -> delegate.embed(document)), + "Spring AI EmbeddingModel delegate returned null from embed(Document)"); try { - langfuseOtel.recordOutput(span, embedding == null - ? "0 embedding(s)" : "1 embedding (" + embedding.length + " dimensions)"); + langfuseOtel.recordOutput(span, + "1 embedding (" + embedding.length + " dimensions)"); } catch (Throwable failure) { InstrumentationFailureSupport.rethrowIfFatal(failure); } @@ -125,14 +139,18 @@ public List embed(List documents, EmbeddingOptions options, InstrumentationFailureSupport.endQuietly(createdSpan); InstrumentationFailureSupport.rethrowIfFatal(failure); log.debug("Langfuse bulk embedding instrumentation setup failed, proceeding without tracing", failure); - return delegate.embed(documents, options, batchingStrategy); + return Objects.requireNonNull( + delegate.embed(documents, options, batchingStrategy), + "Spring AI EmbeddingModel delegate returned null from bulk embed"); } Span span = createdSpan; try { - List embeddings = SpanScopeSupport.call(span, - () -> delegate.embed(documents, options, batchingStrategy)); - int embeddingCount = embeddings != null ? embeddings.size() : 0; + List embeddings = Objects.requireNonNull( + SpanScopeSupport.call(span, + () -> delegate.embed(documents, options, batchingStrategy)), + "Spring AI EmbeddingModel delegate returned null from bulk embed"); + int embeddingCount = embeddings.size(); try { langfuseOtel.recordOutput(span, embeddingCount + " embedding(s)"); } catch (Throwable failure) { diff --git a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingSpringAiImageModel.java b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingSpringAiImageModel.java index 15371d4..f42c807 100644 --- a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingSpringAiImageModel.java +++ b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingSpringAiImageModel.java @@ -15,6 +15,9 @@ import java.util.stream.Collectors; +/** + * Spring AI image model decorator that records Langfuse client spans. + */ public class TracingSpringAiImageModel implements ImageModel { private static final Logger log = LoggerFactory.getLogger(TracingSpringAiImageModel.class); @@ -22,6 +25,12 @@ public class TracingSpringAiImageModel implements ImageModel { private final ImageModel delegate; private final LangfuseOtel langfuseOtel; + /** + * Creates a tracing decorator for a Spring AI image model. + * + * @param delegate model that performs image requests + * @param langfuseOtel tracing integration + */ public TracingSpringAiImageModel(ImageModel delegate, LangfuseOtel langfuseOtel) { this.delegate = delegate; this.langfuseOtel = langfuseOtel; diff --git a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingStreamingLangChain4jChatModel.java b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingStreamingLangChain4jChatModel.java index 374b2a6..5f7049b 100644 --- a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingStreamingLangChain4jChatModel.java +++ b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/TracingStreamingLangChain4jChatModel.java @@ -41,6 +41,9 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; +/** + * LangChain4j streaming chat decorator that records one Langfuse span per request. + */ public class TracingStreamingLangChain4jChatModel implements StreamingChatModel, ChatModel { private static final Logger log = LoggerFactory.getLogger(TracingStreamingLangChain4jChatModel.class); @@ -48,6 +51,12 @@ public class TracingStreamingLangChain4jChatModel implements StreamingChatModel, private final Object delegate; private final LangfuseOtel langfuseOtel; + /** + * Creates a tracing decorator for a streaming or dual-mode LangChain4j chat model. + * + * @param delegate streaming chat model to invoke + * @param langfuseOtel tracing integration + */ public TracingStreamingLangChain4jChatModel(Object delegate, LangfuseOtel langfuseOtel) { this.delegate = delegate; this.langfuseOtel = langfuseOtel; diff --git a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/annotation/ObserveGeneration.java b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/annotation/ObserveGeneration.java index 368c6d4..4e38445 100644 --- a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/annotation/ObserveGeneration.java +++ b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/annotation/ObserveGeneration.java @@ -19,11 +19,15 @@ @Retention(RetentionPolicy.RUNTIME) public @interface ObserveGeneration { + /** @return the span name, or an empty string to use the method name */ String name() default ""; + /** @return the model identifier attached to the generation span */ String model() default ""; + /** @return the model provider or system attached to the generation span */ String system() default ""; + /** @return the generation operation name */ String operation() default "chat"; } diff --git a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/annotation/ObserveGenerationAspect.java b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/annotation/ObserveGenerationAspect.java index ee2155e..756d219 100644 --- a/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/annotation/ObserveGenerationAspect.java +++ b/langfuse-otel-spring-boot-starter/src/main/java/io/github/chomingi/langfuse/otel/spring/annotation/ObserveGenerationAspect.java @@ -19,6 +19,9 @@ import java.util.concurrent.CompletionStage; import java.util.concurrent.ExecutionException; +/** + * Aspect that traces methods marked with {@link ObserveGeneration}. + */ @Aspect public class ObserveGenerationAspect { @@ -26,10 +29,22 @@ public class ObserveGenerationAspect { private final LangfuseOtel langfuseOtel; + /** + * Creates an observation aspect backed by the tracing integration. + * + * @param langfuseOtel tracing integration + */ public ObserveGenerationAspect(LangfuseOtel langfuseOtel) { this.langfuseOtel = langfuseOtel; } + /** + * Traces an annotated invocation and preserves its synchronous or asynchronous result. + * + * @param joinPoint annotated method invocation + * @return the original invocation result or its traced reactive wrapper + * @throws Throwable when the invoked method fails + */ @Around("@annotation(io.github.chomingi.langfuse.otel.spring.annotation.ObserveGeneration)") public Object observe(ProceedingJoinPoint joinPoint) throws Throwable { ObservationDescriptor descriptor; diff --git a/langfuse-otel-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/langfuse-otel-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 8ee26e9..73f4906 100644 --- a/langfuse-otel-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/langfuse-otel-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,3 +1,4 @@ io.github.chomingi.langfuse.otel.spring.LangfuseOtelAutoConfiguration +io.github.chomingi.langfuse.otel.spring.LangfuseOtelObservabilityAutoConfiguration io.github.chomingi.langfuse.otel.spring.SpringAiAutoConfiguration io.github.chomingi.langfuse.otel.spring.LangChain4jAutoConfiguration diff --git a/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/LangChain4jOpenAiExecutorConfigurationTest.java b/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/LangChain4jOpenAiExecutorConfigurationTest.java new file mode 100644 index 0000000..5e68ddf --- /dev/null +++ b/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/LangChain4jOpenAiExecutorConfigurationTest.java @@ -0,0 +1,185 @@ +package io.github.chomingi.langfuse.otel; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import dev.langchain4j.http.client.jdk.JdkHttpClient; +import dev.langchain4j.model.chat.StreamingChatModel; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.chat.response.StreamingChatResponseHandler; +import dev.langchain4j.model.openai.OpenAiStreamingChatModel; +import io.github.chomingi.langfuse.otel.spring.TracingStreamingLangChain4jChatModel; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.context.Scope; +import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension; +import io.opentelemetry.sdk.trace.data.SpanData; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class LangChain4jOpenAiExecutorConfigurationTest { + + private static final byte[] STREAM_RESPONSE = ( + "data: {\"id\":\"chatcmpl-test\",\"object\":\"chat.completion.chunk\"," + + "\"created\":0,\"model\":\"gpt-4o-mini\"," + + "\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"," + + "\"content\":\"hello\"},\"finish_reason\":null}]}\n\n" + + "data: {\"id\":\"chatcmpl-test\",\"object\":\"chat.completion.chunk\"," + + "\"created\":0,\"model\":\"gpt-4o-mini\"," + + "\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]," + + "\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1," + + "\"total_tokens\":2}}\n\n" + + "data: [DONE]\n\n") + .getBytes(StandardCharsets.UTF_8); + + @RegisterExtension + static final OpenTelemetryExtension otel = OpenTelemetryExtension.create(); + + @Test + void tracingWrapperRestoresCallbacksForOpenAiWithConfiguredJdkExecutor() throws Exception { + AtomicReference requestPath = new AtomicReference<>(); + HttpServer receiver = startReceiver(requestPath); + ThreadPoolExecutor providerExecutor = providerExecutor(); + + try { + assertThat(providerExecutor.submit( + LangChain4jOpenAiExecutorConfigurationTest::currentThreadIsClean) + .get(5, TimeUnit.SECONDS)).isTrue(); + long tasksBeforeRequest = providerExecutor.getTaskCount(); + + StreamingChatModel model = new TracingStreamingLangChain4jChatModel( + openAiModel(receiver, providerExecutor), + new LangfuseOtel(null, otel.getOpenTelemetry(), null, true)); + LangfuseTraceContext metadata = LangfuseTraceContext.builder() + .userId("provider-user") + .build(); + CallbackCapture callbacks = new CallbackCapture(); + + try (Scope metadataScope = LangfuseContext.makeCurrent(metadata)) { + model.chat("hello", callbacks); + } + + assertThat(callbacks.awaitTerminal()).isTrue(); + assertThat(callbacks.failure).hasValue(null); + assertThat(requestPath).hasValue("/v1/chat/completions"); + assertThat(callbacks.partial).hasValue("hello"); + assertThat(callbacks.partialContext.get().isValid()).isTrue(); + assertThat(callbacks.completionContext).hasValue(callbacks.partialContext.get()); + assertThat(callbacks.partialUser).hasValue("provider-user"); + assertThat(callbacks.completionUser).hasValue("provider-user"); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(otel.getSpans()) + .singleElement() + .extracting(SpanData::getSpanId) + .isEqualTo(callbacks.completionContext.get().getSpanId())); + assertThat(providerExecutor.getTaskCount()).isGreaterThan(tasksBeforeRequest); + assertThat(providerExecutor.submit( + LangChain4jOpenAiExecutorConfigurationTest::currentThreadIsClean) + .get(5, TimeUnit.SECONDS)).isTrue(); + } finally { + providerExecutor.shutdownNow(); + receiver.stop(0); + } + } + + private static StreamingChatModel openAiModel(HttpServer receiver, Executor executor) { + return OpenAiStreamingChatModel.builder() + .baseUrl("http://127.0.0.1:" + receiver.getAddress().getPort() + "/v1") + .apiKey("test-key") + .modelName("gpt-4o-mini") + .timeout(Duration.ofSeconds(5)) + .httpClientBuilder(JdkHttpClient.builder() + .httpClientBuilder(HttpClient.newBuilder() + .executor(executor))) + .build(); + } + + private static ThreadPoolExecutor providerExecutor() { + return new ThreadPoolExecutor( + 1, + 1, + 0L, + TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<>(), + task -> { + Thread thread = new Thread(task, "langchain4j-openai-http"); + thread.setDaemon(true); + return thread; + }); + } + + private static boolean currentThreadIsClean() { + return !Span.current().getSpanContext().isValid() + && LangfuseContext.getUserId() == null; + } + + private static HttpServer startReceiver(AtomicReference requestPath) throws IOException { + HttpServer receiver = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + receiver.createContext("/", exchange -> { + requestPath.set(exchange.getRequestURI().getPath()); + sendStream(exchange); + }); + receiver.start(); + return receiver; + } + + private static void sendStream(HttpExchange exchange) throws IOException { + try { + exchange.getRequestBody().readAllBytes(); + exchange.getResponseHeaders().set("Content-Type", "text/event-stream"); + exchange.sendResponseHeaders(200, STREAM_RESPONSE.length); + exchange.getResponseBody().write(STREAM_RESPONSE); + } finally { + exchange.close(); + } + } + + private static final class CallbackCapture implements StreamingChatResponseHandler { + private final AtomicReference partial = new AtomicReference<>(); + private final AtomicReference partialContext = new AtomicReference<>(); + private final AtomicReference completionContext = new AtomicReference<>(); + private final AtomicReference partialUser = new AtomicReference<>(); + private final AtomicReference completionUser = new AtomicReference<>(); + private final AtomicReference failure = new AtomicReference<>(); + private final CountDownLatch terminal = new CountDownLatch(1); + + @Override + public void onPartialResponse(String text) { + partial.set(text); + partialContext.set(Span.current().getSpanContext()); + partialUser.set(LangfuseContext.getUserId()); + } + + @Override + public void onCompleteResponse(ChatResponse response) { + completionContext.set(Span.current().getSpanContext()); + completionUser.set(LangfuseContext.getUserId()); + terminal.countDown(); + } + + @Override + public void onError(Throwable error) { + failure.set(error); + terminal.countDown(); + } + + private boolean awaitTerminal() throws InterruptedException { + return terminal.await(5, TimeUnit.SECONDS); + } + } +} diff --git a/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/TracingSpringAiEmbeddingModelTest.java b/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/TracingSpringAiEmbeddingModelTest.java index 28301e7..fcb9289 100644 --- a/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/TracingSpringAiEmbeddingModelTest.java +++ b/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/TracingSpringAiEmbeddingModelTest.java @@ -154,6 +154,18 @@ void documentEmbeddingIsDelegatedAndTraced() { .isEqualTo("1 embedding (3 dimensions)"); } + @Test + void documentEmbeddingRejectsNullDelegateResult() { + EmbeddingModel proxy = proxy(new NullReturningSpringAiEmbeddingModel()); + + assertThatThrownBy(() -> proxy.embed(new Document("document embedding input"))) + .isInstanceOf(NullPointerException.class) + .hasMessage("Spring AI EmbeddingModel delegate returned null from embed(Document)"); + + assertThat(otel.getSpans()).hasSize(1); + assertThat(otel.getSpans().get(0).getStatus().getStatusCode().name()).isEqualTo("ERROR"); + } + @Test void bulkDocumentEmbeddingPreservesProviderOverrideAndIsTraced() { BulkOverrideSpringAiEmbeddingModel target = new BulkOverrideSpringAiEmbeddingModel(); @@ -178,6 +190,20 @@ void bulkDocumentEmbeddingPreservesProviderOverrideAndIsTraced() { .isEqualTo("2 embedding(s)"); } + @Test + void bulkDocumentEmbeddingRejectsNullDelegateResult() { + EmbeddingModel proxy = proxy(new NullReturningSpringAiEmbeddingModel()); + List documents = List.of(new Document("first"), new Document("second")); + BatchingStrategy batchingStrategy = input -> List.of(input); + + assertThatThrownBy(() -> proxy.embed(documents, null, batchingStrategy)) + .isInstanceOf(NullPointerException.class) + .hasMessage("Spring AI EmbeddingModel delegate returned null from bulk embed"); + + assertThat(otel.getSpans()).hasSize(1); + assertThat(otel.getSpans().get(0).getStatus().getStatusCode().name()).isEqualTo("ERROR"); + } + @Test void dimensionsDelegatesProviderOverrideWithoutCreatingAnEmbeddingSpan() { EmbeddingModel proxy = proxy(new StubSpringAiEmbeddingModel()); @@ -274,6 +300,19 @@ boolean bulkInvoked() { } } + static class NullReturningSpringAiEmbeddingModel extends StubSpringAiEmbeddingModel { + @Override + public float[] embed(Document document) { + return null; + } + + @Override + public List embed(List documents, EmbeddingOptions options, + BatchingStrategy batchingStrategy) { + return null; + } + } + static class StubUsage implements Usage { private final Integer promptTokens; private final Integer completionTokens; diff --git a/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/TracingStreamingLangChain4jChatModelTest.java b/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/TracingStreamingLangChain4jChatModelTest.java index 5333249..997f721 100644 --- a/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/TracingStreamingLangChain4jChatModelTest.java +++ b/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/TracingStreamingLangChain4jChatModelTest.java @@ -264,7 +264,7 @@ public void onError(Throwable error) {} @Test void activeStateStorageFailureIsNonFatalAndTheSpanStillEnds() { - FailingContext failingContext = new FailingContext(3); + FailingStateContext failingContext = new FailingStateContext(); AtomicReference completedResponse = new AtomicReference<>(); io.github.chomingi.langfuse.otel.spring.TracingStreamingLangChain4jChatModel proxy = new io.github.chomingi.langfuse.otel.spring.TracingStreamingLangChain4jChatModel( @@ -277,13 +277,13 @@ void activeStateStorageFailureIsNonFatalAndTheSpanStillEnds() { } assertThat(completedResponse.get().aiMessage().text()).isEqualTo("HelloWorld"); - assertThat(failingContext.writes).hasValue(3); + assertThat(failingContext.stateWrites).hasValue(1); assertThat(otel.getSpans()).hasSize(1); } @Test void fallbackStateStorageFailureIsNonFatalAndDoesNotRetryUnsafely() { - FailingContext failingContext = new FailingContext(2); + FailingStateContext failingContext = new FailingStateContext(); AtomicReference completedResponse = new AtomicReference<>(); io.github.chomingi.langfuse.otel.spring.TracingStreamingLangChain4jChatModel proxy = new io.github.chomingi.langfuse.otel.spring.TracingStreamingLangChain4jChatModel( @@ -296,7 +296,7 @@ void fallbackStateStorageFailureIsNonFatalAndDoesNotRetryUnsafely() { } assertThat(completedResponse.get().aiMessage().text()).isEqualTo("fallback response"); - assertThat(failingContext.writes).hasValue(2); + assertThat(failingContext.stateWrites).hasValue(1); assertThat(otel.getSpans()).hasSize(1); } @@ -1221,13 +1221,10 @@ private StreamingChatModel streamingProxy(Object target, LangfuseOtel langfuseOt langfuseOtel); } - static class FailingContext implements Context { - private final int failingWrite; - private final AtomicInteger writes = new AtomicInteger(); - - FailingContext(int failingWrite) { - this.failingWrite = failingWrite; - } + static class FailingStateContext implements Context { + private static final String STREAMING_STATE_KEY = + "langfuse-langchain4j-streaming-state"; + private final AtomicInteger stateWrites = new AtomicInteger(); @Override public V get(ContextKey key) { @@ -1236,7 +1233,8 @@ public V get(ContextKey key) { @Override public Context with(ContextKey key, V value) { - if (writes.incrementAndGet() == failingWrite) { + if (STREAMING_STATE_KEY.equals(key.toString())) { + stateWrites.incrementAndGet(); throw new IllegalStateException("context state storage unavailable"); } return this; diff --git a/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/TracingStreamingSpringAiChatModelTest.java b/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/TracingStreamingSpringAiChatModelTest.java index 805f169..fc3f436 100644 --- a/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/TracingStreamingSpringAiChatModelTest.java +++ b/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/TracingStreamingSpringAiChatModelTest.java @@ -138,7 +138,7 @@ void nonFatalContentPolicySetupFailureFallsBackWithoutStartingASpan() { void nonFatalInvocationContextFailureEndsTheStartedSpanAndFallsBack() { AssemblyContextStreamingChatModel target = new AssemblyContextStreamingChatModel(); ChatModel proxy = proxy(target); - FailingOnSecondWriteContext failingContext = new FailingOnSecondWriteContext(); + FailingRecordingSpanContext failingContext = new FailingRecordingSpanContext(); LangfuseTraceContext traceContext = LangfuseTraceContext.builder() .userId("failing-context-user") .build(); @@ -151,7 +151,7 @@ void nonFatalInvocationContextFailureEndsTheStartedSpanAndFallsBack() { .block(); assertThat(responses).hasSize(1); - assertThat(failingContext.writes()).isEqualTo(2); + assertThat(failingContext.rejectedWrites()).isEqualTo(1); assertThat(target.assemblySpanId.get()).isEqualTo( Span.getInvalid().getSpanContext().getSpanId()); assertThat(otel.getSpans()).hasSize(1); @@ -589,18 +589,18 @@ int policyLookups() { } } - static class FailingOnSecondWriteContext implements Context { + static class FailingRecordingSpanContext implements Context { private final Context delegate; - private final AtomicInteger writes; + private final AtomicInteger rejectedWrites; - FailingOnSecondWriteContext() { + FailingRecordingSpanContext() { this(Context.root(), new AtomicInteger()); } - private FailingOnSecondWriteContext(Context delegate, AtomicInteger writes) { + private FailingRecordingSpanContext(Context delegate, AtomicInteger rejectedWrites) { this.delegate = delegate; - this.writes = writes; + this.rejectedWrites = rejectedWrites; } @Override @@ -610,14 +610,16 @@ public V get(ContextKey key) { @Override public Context with(ContextKey key, V value) { - if (writes.incrementAndGet() > 1) { + if (value instanceof Span && ((Span) value).isRecording()) { + rejectedWrites.incrementAndGet(); throw new AssertionError("span context enrichment unavailable"); } - return new FailingOnSecondWriteContext(delegate.with(key, value), writes); + return new FailingRecordingSpanContext( + delegate.with(key, value), rejectedWrites); } - int writes() { - return writes.get(); + int rejectedWrites() { + return rejectedWrites.get(); } } diff --git a/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelAutoConfigurationCompatibilityTest.java b/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelAutoConfigurationCompatibilityTest.java new file mode 100644 index 0000000..64468d9 --- /dev/null +++ b/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelAutoConfigurationCompatibilityTest.java @@ -0,0 +1,45 @@ +package io.github.chomingi.langfuse.otel.spring; + +import io.github.chomingi.langfuse.otel.LangfuseOtel; +import io.github.chomingi.langfuse.otel.LangfuseOtelStatus; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Bean; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class LangfuseOtelAutoConfigurationCompatibilityTest { + + @Test + @SuppressWarnings("deprecation") + void legacyFactoryOverloadUsesStandaloneDefaultsWithoutSpringBeanLookup() { + LangfuseOtelProperties properties = new LangfuseOtelProperties(); + properties.getContent().setCaptureInput(true); + properties.getException().setCaptureMessage(true); + + try (LangfuseOtel langfuse = new LangfuseOtelAutoConfiguration().langfuseOtel(properties)) { + assertThat(langfuse.isNoop()).isTrue(); + assertThat(langfuse.getStatus().getNoopReason()) + .isEqualTo(LangfuseOtelStatus.NoopReason.MISSING_CREDENTIALS); + assertThat(langfuse.getContentCapturePolicy().isInputCaptureEnabled()).isTrue(); + assertThat(langfuse.getExceptionCapturePolicy().isMessageCaptureEnabled()).isTrue(); + } + } + + @Test + void legacyFactoryDescriptorDoesNotRegisterASecondBeanFactory() throws NoSuchMethodException { + Method legacyFactory = LangfuseOtelAutoConfiguration.class + .getMethod("langfuseOtel", LangfuseOtelProperties.class); + + assertThat(legacyFactory.getReturnType()).isEqualTo(LangfuseOtel.class); + assertThat(legacyFactory.isAnnotationPresent(Deprecated.class)).isTrue(); + assertThat(legacyFactory.isAnnotationPresent(Bean.class)).isFalse(); + assertThat(Arrays.stream(LangfuseOtelAutoConfiguration.class.getDeclaredMethods()) + .filter(method -> method.getName().equals("langfuseOtel")) + .filter(method -> method.isAnnotationPresent(Bean.class))) + .singleElement() + .satisfies(method -> assertThat(method.getParameterCount()).isEqualTo(4)); + } +} diff --git a/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelAutoConfigurationTest.java b/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelAutoConfigurationTest.java index 9b6cebc..106363d 100644 --- a/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelAutoConfigurationTest.java +++ b/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelAutoConfigurationTest.java @@ -41,7 +41,8 @@ void starterRegistersCoreAndAspectBeans() { assertThat(context).hasSingleBean(ObserveGenerationAspect.class); assertThat(context).hasSingleBean(SpringAiChatModelBeanPostProcessor.class); assertThat(context).hasSingleBean(LangChain4jChatModelBeanPostProcessor.class); - assertThat(context).hasSingleBean(LangfuseContextFilter.class); + assertThat(context).doesNotHaveBean(LangfuseContextFilter.class); + assertThat(context).doesNotHaveBean(LangfuseReactiveContextFilter.class); }); } diff --git a/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelHealthIndicatorTest.java b/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelHealthIndicatorTest.java new file mode 100644 index 0000000..f822ce6 --- /dev/null +++ b/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelHealthIndicatorTest.java @@ -0,0 +1,55 @@ +package io.github.chomingi.langfuse.otel.spring; + +import io.github.chomingi.langfuse.otel.LangfuseOtel; +import io.github.chomingi.langfuse.otel.LangfuseOtelStatus; +import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.Status; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LangfuseOtelHealthIndicatorTest { + + @Test + void currentOwnedPipelineFailuresAreDownAndLaterSuccessRecovers() { + LangfuseOtel langfuse = mock(LangfuseOtel.class); + LangfuseOtelStatus runtime = ownedRuntime(); + when(langfuse.getStatus()).thenReturn(runtime); + LangfuseOtelHealthIndicator indicator = new LangfuseOtelHealthIndicator(langfuse); + + when(runtime.getExportState()).thenReturn(LangfuseOtelStatus.ExportState.FAILED); + assertThat(indicator.health().getStatus()).isEqualTo(Status.DOWN); + + when(runtime.getExportState()).thenReturn(LangfuseOtelStatus.ExportState.SUCCEEDED); + when(runtime.hasQueueDropsSinceLastSuccessfulExport()).thenReturn(true); + assertThat(indicator.health().getStatus()).isEqualTo(Status.DOWN); + + when(runtime.hasQueueDropsSinceLastSuccessfulExport()).thenReturn(false); + when(runtime.getFlushState()).thenReturn(LangfuseOtelStatus.FlushState.FAILED); + assertThat(indicator.health().getStatus()).isEqualTo(Status.DOWN); + + when(runtime.getFlushState()).thenReturn(LangfuseOtelStatus.FlushState.TIMED_OUT); + assertThat(indicator.health().getStatus()).isEqualTo(Status.DOWN); + + when(runtime.getFlushState()).thenReturn(LangfuseOtelStatus.FlushState.SUCCEEDED); + Health recovered = indicator.health(); + assertThat(recovered.getStatus()).isEqualTo(Status.UP); + assertThat(recovered.getDetails()) + .containsEntry("exportState", "succeeded") + .containsEntry("flushState", "succeeded"); + } + + private static LangfuseOtelStatus ownedRuntime() { + LangfuseOtelStatus runtime = mock(LangfuseOtelStatus.class); + when(runtime.getOpenTelemetryOwnership()) + .thenReturn(LangfuseOtel.OpenTelemetryOwnership.OWNED); + when(runtime.isNoopFallback()).thenReturn(false); + when(runtime.getNoopReason()).thenReturn(LangfuseOtelStatus.NoopReason.NONE); + when(runtime.isOperationalSignalsAvailable()).thenReturn(true); + when(runtime.getExportState()).thenReturn(LangfuseOtelStatus.ExportState.NOT_ATTEMPTED); + when(runtime.getFlushState()).thenReturn(LangfuseOtelStatus.FlushState.NOT_REQUESTED); + return runtime; + } +} diff --git a/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelMeterBinderTest.java b/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelMeterBinderTest.java new file mode 100644 index 0000000..a1d5ff6 --- /dev/null +++ b/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelMeterBinderTest.java @@ -0,0 +1,88 @@ +package io.github.chomingi.langfuse.otel.spring; + +import io.github.chomingi.langfuse.otel.LangfuseOtel; +import io.github.chomingi.langfuse.otel.LangfuseOtelStatus; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LangfuseOtelMeterBinderTest { + + @Test + void managedMetersFollowCoreSnapshotWithoutRebinding() { + AtomicLong failedExports = new AtomicLong(); + AtomicLong queueDrops = new AtomicLong(); + AtomicLong flushFailures = new AtomicLong(); + AtomicLong flushTimeouts = new AtomicLong(); + AtomicReference flushState = + new AtomicReference<>(LangfuseOtelStatus.FlushState.NOT_REQUESTED); + + LangfuseOtelStatus status = mock(LangfuseOtelStatus.class); + when(status.getOpenTelemetryOwnership()) + .thenReturn(LangfuseOtel.OpenTelemetryOwnership.OWNED); + when(status.isNoopFallback()).thenReturn(false); + when(status.getNoopReason()).thenReturn(LangfuseOtelStatus.NoopReason.NONE); + when(status.isOperationalSignalsAvailable()).thenReturn(true); + when(status.getFailedExportSpanCount()).thenAnswer(ignored -> failedExports.get()); + when(status.getQueueDroppedSpanCount()).thenAnswer(ignored -> queueDrops.get()); + when(status.getFailedFlushCount()).thenAnswer(ignored -> flushFailures.get()); + when(status.getTimedOutFlushCount()).thenAnswer(ignored -> flushTimeouts.get()); + when(status.getFlushState()).thenAnswer(ignored -> flushState.get()); + + LangfuseOtel langfuse = mock(LangfuseOtel.class); + when(langfuse.getStatus()).thenReturn(status); + when(langfuse.isNoop()).thenReturn(false); + + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + try { + new LangfuseOtelMeterBinder(langfuse).bindTo(registry); + + assertThat(registry.get("langfuse.otel.noop.fallback") + .tag("ownership", "owned") + .tag("fallback_reason", "none") + .gauge().value()).isZero(); + assertThat(registry.get("langfuse.otel.export.failed.spans") + .functionCounter().count()).isZero(); + assertThat(registry.get("langfuse.otel.queue.dropped.spans") + .functionCounter().count()).isZero(); + assertThat(registry.get("langfuse.otel.flush.state") + .tag("state", "not_requested").gauge().value()).isEqualTo(1.0); + assertThat(registry.get("langfuse.otel.flush.state") + .tag("state", "in_progress").gauge().value()).isZero(); + + failedExports.set(3); + queueDrops.set(2); + flushFailures.set(1); + flushTimeouts.set(1); + flushState.set(LangfuseOtelStatus.FlushState.IN_PROGRESS); + + assertThat(registry.get("langfuse.otel.export.failed.spans") + .functionCounter().count()).isEqualTo(3.0); + assertThat(registry.get("langfuse.otel.queue.dropped.spans") + .functionCounter().count()).isEqualTo(2.0); + assertThat(registry.get("langfuse.otel.flush.failures") + .functionCounter().count()).isEqualTo(1.0); + assertThat(registry.get("langfuse.otel.flush.timeouts") + .functionCounter().count()).isEqualTo(1.0); + assertThat(registry.get("langfuse.otel.flush.state") + .tag("state", "not_requested").gauge().value()).isZero(); + assertThat(registry.get("langfuse.otel.flush.state") + .tag("state", "in_progress").gauge().value()).isEqualTo(1.0); + + flushState.set(LangfuseOtelStatus.FlushState.SUCCEEDED); + assertThat(registry.get("langfuse.otel.flush.state") + .tag("state", "in_progress").gauge().value()).isZero(); + assertThat(registry.get("langfuse.otel.flush.state") + .tag("state", "succeeded") + .gauge().value()).isEqualTo(1.0); + } finally { + registry.close(); + } + } +} diff --git a/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelObservabilityAutoConfigurationTest.java b/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelObservabilityAutoConfigurationTest.java new file mode 100644 index 0000000..2b03578 --- /dev/null +++ b/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelObservabilityAutoConfigurationTest.java @@ -0,0 +1,207 @@ +package io.github.chomingi.langfuse.otel.spring; + +import io.github.chomingi.langfuse.otel.LangfuseOtel; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.binder.MeterBinder; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.opentelemetry.api.OpenTelemetry; +import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.autoconfigure.health.ConditionalOnEnabledHealthIndicator; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.boot.actuate.health.Status; +import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.metrics.export.simple.SimpleMetricsExportAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +class LangfuseOtelObservabilityAutoConfigurationTest { + + private final ApplicationContextRunner baseRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of( + LangfuseOtelAutoConfiguration.class, + LangfuseOtelObservabilityAutoConfiguration.class)); + + private final ApplicationContextRunner standaloneRunner = baseRunner.withPropertyValues( + "langfuse.public-key=pk-test", + "langfuse.secret-key=sk-test"); + + @Test + void standaloneRegistersUpHealthAndManagedMeterBinder() { + standaloneRunner.run(context -> { + assertThat(context).hasSingleBean(LangfuseOtel.class); + assertThat(context).hasBean("langfuseHealthIndicator"); + assertThat(context).hasBean("langfuseOtelMeterBinder"); + + Health health = context.getBean("langfuseHealthIndicator", HealthIndicator.class).health(); + assertThat(health.getStatus()).isEqualTo(Status.UP); + assertThat(health.getDetails()) + .containsEntry("ownership", "owned") + .containsEntry("operationalSignals", "managed") + .containsEntry("exportState", "not_attempted") + .doesNotContainKeys("host", "endpoint", "publicKey", "secretKey"); + }); + } + + @Test + void failSafeNoopIsOutOfServiceWithBoundedReason() { + baseRunner.run(context -> { + Health health = context.getBean("langfuseHealthIndicator", HealthIndicator.class).health(); + + assertThat(health.getStatus()).isEqualTo(Status.OUT_OF_SERVICE); + assertThat(health.getDetails()) + .containsEntry("ownership", "none") + .containsEntry("noopFallback", true) + .containsEntry("noopReason", "missing_credentials") + .containsEntry("operationalSignals", "not_managed"); + + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + try { + context.getBean("langfuseOtelMeterBinder", MeterBinder.class).bindTo(registry); + assertThat(registry.get("langfuse.otel.noop.fallback") + .tag("ownership", "none") + .tag("fallback_reason", "missing_credentials") + .gauge().value()).isEqualTo(1.0); + assertThat(registry.find("langfuse.otel.export.failed.spans").meter()).isNull(); + } finally { + registry.close(); + } + }); + } + + @Test + void externalPipelineIsUnknownAndDoesNotPublishTransportMeters() { + baseRunner.withBean(OpenTelemetry.class, OpenTelemetry::noop) + .run(context -> { + Health health = context.getBean( + "langfuseHealthIndicator", HealthIndicator.class).health(); + assertThat(health.getStatus()).isEqualTo(Status.UNKNOWN); + assertThat(health.getDetails()) + .containsEntry("ownership", "external") + .containsEntry("operationalSignals", "not_managed"); + + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + try { + context.getBean("langfuseOtelMeterBinder", MeterBinder.class).bindTo(registry); + assertThat(registry.get("langfuse.otel.noop.fallback") + .tag("ownership", "external") + .tag("fallback_reason", "none") + .gauge().value()).isZero(); + assertThat(registry.find("langfuse.otel.export.failed.spans").meter()).isNull(); + assertThat(registry.find("langfuse.otel.queue.dropped.spans").meter()).isNull(); + assertThat(registry.find("langfuse.otel.flush.failures").meter()).isNull(); + } finally { + registry.close(); + } + }); + } + + @Test + void optionalClasspathConditionsLeaveCoreIntegrationUsable() { + standaloneRunner + .withClassLoader(new FilteredClassLoader( + HealthIndicator.class, ConditionalOnEnabledHealthIndicator.class)) + .run(context -> { + assertThat(context).hasSingleBean(LangfuseOtel.class); + assertThat(context).doesNotHaveBean("langfuseHealthIndicator"); + assertThat(context).hasBean("langfuseOtelMeterBinder"); + }); + + standaloneRunner + .withClassLoader(new FilteredClassLoader( + MeterRegistry.class, MeterBinder.class)) + .run(context -> { + assertThat(context).hasSingleBean(LangfuseOtel.class); + assertThat(context).hasBean("langfuseHealthIndicator"); + assertThat(context).doesNotHaveBean("langfuseOtelMeterBinder"); + }); + + standaloneRunner + .withClassLoader(new FilteredClassLoader( + HealthIndicator.class, ConditionalOnEnabledHealthIndicator.class, + MeterRegistry.class, MeterBinder.class)) + .run(context -> { + assertThat(context).hasSingleBean(LangfuseOtel.class); + assertThat(context).doesNotHaveBean("langfuseHealthIndicator"); + assertThat(context).doesNotHaveBean("langfuseOtelMeterBinder"); + }); + } + + @Test + void standardHealthDisablePropertyIsHonored() { + standaloneRunner.withPropertyValues("management.health.langfuse.enabled=false") + .run(context -> { + assertThat(context).doesNotHaveBean("langfuseHealthIndicator"); + assertThat(context).hasBean("langfuseOtelMeterBinder"); + }); + } + + @Test + void globalHealthDisablePropertyIsHonored() { + standaloneRunner.withPropertyValues("management.health.defaults.enabled=false") + .run(context -> assertThat(context) + .doesNotHaveBean("langfuseHealthIndicator")); + } + + @Test + void bootMetricsAutoConfigurationBindsMetersAutomatically() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of( + LangfuseOtelAutoConfiguration.class, + LangfuseOtelObservabilityAutoConfiguration.class, + MetricsAutoConfiguration.class, + SimpleMetricsExportAutoConfiguration.class)) + .withPropertyValues( + "langfuse.public-key=pk-test", + "langfuse.secret-key=sk-test") + .run(context -> { + MeterRegistry registry = context.getBean(MeterRegistry.class); + assertThat(registry.find("langfuse.otel.noop.fallback").gauge()).isNotNull(); + assertThat(registry.find("langfuse.otel.export.failed.spans") + .functionCounter()).isNotNull(); + }); + } + + @Test + void standardMetricsDisablePropertyIsHonored() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of( + LangfuseOtelAutoConfiguration.class, + LangfuseOtelObservabilityAutoConfiguration.class, + MetricsAutoConfiguration.class, + SimpleMetricsExportAutoConfiguration.class)) + .withPropertyValues( + "langfuse.public-key=pk-test", + "langfuse.secret-key=sk-test", + "management.metrics.enable.langfuse=false") + .run(context -> assertThat(context.getBean(MeterRegistry.class) + .find("langfuse.otel.noop.fallback").meter()).isNull()); + } + + @Test + void disablingLangfuseRemovesBothOperationalBeans() { + standaloneRunner.withPropertyValues("langfuse.enabled=false") + .run(context -> { + assertThat(context).doesNotHaveBean(LangfuseOtel.class); + assertThat(context).doesNotHaveBean("langfuseHealthIndicator"); + assertThat(context).doesNotHaveBean("langfuseOtelMeterBinder"); + }); + } + + @Test + void namedUserBeansTakePrecedence() { + HealthIndicator userHealth = () -> Health.up().withDetail("source", "user").build(); + MeterBinder userBinder = registry -> {}; + + standaloneRunner + .withBean("langfuseHealthIndicator", HealthIndicator.class, () -> userHealth) + .withBean("langfuseOtelMeterBinder", MeterBinder.class, () -> userBinder) + .run(context -> { + assertThat(context.getBean("langfuseHealthIndicator")).isSameAs(userHealth); + assertThat(context.getBean("langfuseOtelMeterBinder")).isSameAs(userBinder); + }); + } +} diff --git a/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelWebApplicationAutoConfigurationTest.java b/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelWebApplicationAutoConfigurationTest.java new file mode 100644 index 0000000..355473f --- /dev/null +++ b/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/spring/LangfuseOtelWebApplicationAutoConfigurationTest.java @@ -0,0 +1,40 @@ +package io.github.chomingi.langfuse.otel.spring; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +class LangfuseOtelWebApplicationAutoConfigurationTest { + + private final WebApplicationContextRunner servletContextRunner = new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(LangfuseOtelAutoConfiguration.class)) + .withPropertyValues( + "langfuse.public-key=pk-test", + "langfuse.secret-key=sk-test"); + + private final ReactiveWebApplicationContextRunner reactiveContextRunner = + new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(LangfuseOtelAutoConfiguration.class)) + .withPropertyValues( + "langfuse.public-key=pk-test", + "langfuse.secret-key=sk-test"); + + @Test + void mixedClasspathServletApplicationRegistersOnlyServletFilter() { + servletContextRunner.run(context -> { + assertThat(context).hasSingleBean(LangfuseContextFilter.class); + assertThat(context).doesNotHaveBean(LangfuseReactiveContextFilter.class); + }); + } + + @Test + void mixedClasspathReactiveApplicationRegistersOnlyReactiveFilter() { + reactiveContextRunner.run(context -> { + assertThat(context).doesNotHaveBean(LangfuseContextFilter.class); + assertThat(context).hasSingleBean(LangfuseReactiveContextFilter.class); + }); + } +} diff --git a/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/spring/PackagedJarManifestIT.java b/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/spring/PackagedJarManifestIT.java new file mode 100644 index 0000000..6f77d36 --- /dev/null +++ b/langfuse-otel-spring-boot-starter/src/test/java/io/github/chomingi/langfuse/otel/spring/PackagedJarManifestIT.java @@ -0,0 +1,24 @@ +package io.github.chomingi.langfuse.otel.spring; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.jar.Attributes; +import java.util.jar.JarFile; + +import static org.assertj.core.api.Assertions.assertThat; + +class PackagedJarManifestIT { + + @Test + void packagedJarDeclaresItsImplementationVersion() throws IOException { + String packagedJar = System.getProperty("packagedJar"); + String expectedVersion = System.getProperty("expectedImplementationVersion"); + + try (JarFile jarFile = new JarFile(packagedJar)) { + assertThat(jarFile.getManifest().getMainAttributes() + .getValue(Attributes.Name.IMPLEMENTATION_VERSION)) + .isEqualTo(expectedVersion); + } + } +} diff --git a/pom.xml b/pom.xml index e1a77f0..1e3dda4 100644 --- a/pom.xml +++ b/pom.xml @@ -41,18 +41,58 @@ UTF-8 2026-07-19T00:00:00Z - 1.44.1 - 2.0.17 - 3.4.0 - 5.10.2 + 1.62.0 + 1.3.2-alpha + 2.0.18 + 3.5.16 + 2.21.5 + 1.9.25 + 4.12.0 + 4.1.136.Final 3.27.7 - 1.0.0 + 1.0.9 1.0.0 + 0.8.15 + 4.10.3.0 + 2.7.1 + 2.9.2 + 0.26.1 + 0.1.1 + 0.000 + 0.000 integration,e2e + + com.fasterxml.jackson + jackson-bom + ${jackson.version} + pom + import + + + org.jetbrains.kotlin + kotlin-bom + ${kotlin.version} + pom + import + + + com.squareup.okhttp3 + okhttp-bom + ${okhttp.version} + pom + import + + + io.netty + netty-bom + ${netty.version} + pom + import + io.opentelemetry opentelemetry-bom @@ -61,9 +101,9 @@ import - org.junit - junit-bom - ${junit.version} + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} pom import @@ -72,6 +112,61 @@ + + org.apache.maven.plugins + maven-enforcer-plugin + 3.6.3 + + + enforce-dependency-convergence + + enforce + + + + + + + + + + + org.cyclonedx + cyclonedx-maven-plugin + ${cyclonedx-maven-plugin.version} + false + + + generate-aggregate-sbom + package + + makeAggregateBom + + + library + 1.6 + false + false + json + bom + false + false + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.5.0 + + + + ${project.version} + + + + org.apache.maven.plugins maven-surefire-plugin @@ -80,6 +175,26 @@ ${test.excludedGroups} + + org.apache.maven.plugins + maven-failsafe-plugin + 3.2.5 + + + + integration-test + verify + + + + + ${test.excludedGroups} + + ${project.build.directory}/${project.build.finalName}.jar + ${project.version} + + + org.apache.maven.plugins maven-source-plugin @@ -96,6 +211,9 @@ org.apache.maven.plugins maven-javadoc-plugin 3.6.3 + + true + attach-javadocs @@ -104,10 +222,165 @@ + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + prepare-coverage-agent + initialize + prepare-agent + + + report-coverage + verify + report + + + check-coverage + verify + check + + + + BUNDLE + + + LINE + COVEREDRATIO + ${jacoco.minimum.line.coverage} + + + BRANCH + COVEREDRATIO + ${jacoco.minimum.branch.coverage} + + + + + + + + + + com.github.siom79.japicmp + japicmp-maven-plugin + ${japicmp-maven-plugin.version} + + + + ${project.groupId} + ${project.artifactId} + ${api.compatibility.baseline} + jar + + + + + ${project.build.directory}/${project.build.finalName}.jar + + + + true + protected + false + true + true + true + + + + + check-api-compatibility + verify + + cmp + + + + + + quality + + + + com.github.spotbugs + spotbugs-maven-plugin + ${spotbugs-maven-plugin.version} + + Max + Medium + false + true + true + true + ${maven.multiModuleProjectDirectory}/config/spotbugs-exclude.xml + + + + check-static-analysis + verify + + check + + + + + + org.codehaus.mojo + license-maven-plugin + ${license-maven-plugin.version} + false + + + check-aggregate-dependency-licenses + verify + + aggregate-add-third-party + + + true + true + true + test + true + true + true + false + false + THIRD-PARTY.txt + + Apache 2.0 + Apache License 2.0 + Apache License, Version 2.0 + Apache-2.0 + The Apache License, Version 2.0 + The Apache Software License, Version 2.0 + BSD licence + BSD-2-Clause + BSD-3-Clause + The BSD License + EPL 2.0 + EPL-2.0 + Eclipse Public License - v 2.0 + Eclipse Public License v2.0 + MIT + MIT License + MIT-0 + Public Domain, per Creative Commons CC0 + + + + + + + + release