diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000000..5c250631c59 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +# Gradle-Build +**/build/ +**/.gradle/ +.gradle/ + +# cached Gradle-Wrapper +gradle/wrapper/gradle-wrapper.jar.sha256 + +# IDE-Files +.idea/ +.vscode/ +*.iml +.classpath +.project +.settings/ + +# OS artefacts +.DS_Store +Thumbs.db + +# DO NOT ignore: +# .git/ → required by jApiCmp for Versions-Checks +# docs/apidiffs/ → required by jApiCmp-Diff-Check +# gradlew → required by Build diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index a28f8898c4b..dd7d8f4954e 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -11,6 +11,7 @@ permissions: jobs: backport: + if: github.repository == 'open-telemetry/opentelemetry-java' permissions: contents: write # for git push to PR branch runs-on: ubuntu-latest diff --git a/.github/workflows/benchmark-tags.yml b/.github/workflows/benchmark-tags.yml index fd029f3caf7..95b44af6c65 100644 --- a/.github/workflows/benchmark-tags.yml +++ b/.github/workflows/benchmark-tags.yml @@ -8,6 +8,7 @@ permissions: jobs: sdk-benchmark: + if: github.repository == 'open-telemetry/opentelemetry-java' permissions: contents: write # for git push to benchmarks branch name: Benchmark SDK diff --git a/.github/workflows/build-daily.yml b/.github/workflows/build-daily.yml index 8bc79e530fb..ec56fab0d91 100644 --- a/.github/workflows/build-daily.yml +++ b/.github/workflows/build-daily.yml @@ -53,5 +53,5 @@ jobs: success: >- ${{ needs.link-check.result == 'success' && - needs.publish-snapshots.result == 'success' + (needs.publish-snapshots.result == 'success' || needs.publish-snapshots.result == 'skipped') }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0f36f585d72..996f5d467eb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -69,6 +69,8 @@ jobs: # JMH-based tests run only if this environment variable is set to true RUN_JMH_BASED_TESTS: ${{ matrix.jmh-based-tests }} DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_ACTOR: ${{ github.actor }} - name: Check for diff # The jApiCmp diff compares current to latest, which isn't appropriate for release branches @@ -134,6 +136,8 @@ jobs: ./gradlew nativeTest --no-configuration-cache --no-parallel env: DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_ACTOR: ${{ github.actor }} required-status-check: # markdown-link-check is not required so pull requests are not blocked if external links break diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f2f7d60d5a0..921c4243cde 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -60,6 +60,8 @@ jobs: run: ./gradlew assemble --no-build-cache --no-daemon env: DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_ACTOR: ${{ github.actor }} - name: Perform CodeQL analysis uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 diff --git a/.github/workflows/publish-snapshot.yml b/.github/workflows/publish-snapshot.yml new file mode 100644 index 00000000000..0c8a34c1d02 --- /dev/null +++ b/.github/workflows/publish-snapshot.yml @@ -0,0 +1,34 @@ +name: Publish Snapshot + +on: + push: + branches: + - main + - auditing + +permissions: + contents: read + packages: write + +jobs: + publish: + name: Publish SNAPSHOT to GitHub Packages + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Set up Java + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: sapmachine + java-version: 25 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + + - name: Publish SNAPSHOT artifacts + run: ./gradlew assemble publishAllPublicationsToGithubPackagesRepository + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_ACTOR: ${{ github.actor }} + DISABLE_REMOTE_BUILD_CACHE: true diff --git a/.github/workflows/sync-fork.yaml b/.github/workflows/sync-fork.yaml new file mode 100644 index 00000000000..de520f2bc0e --- /dev/null +++ b/.github/workflows/sync-fork.yaml @@ -0,0 +1,16 @@ +name: sync-fork +on: + schedule: + - cron: '4 2 * * 4,2' + workflow_dispatch: +jobs: + sync-fork: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - run: gh repo sync $REPOSITORY -b $BRANCH_NAME + env: + GITHUB_TOKEN: ${{ secrets.SYNC_FORK_TOKEN }} + REPOSITORY: ${{ github.repository }} + BRANCH_NAME: ${{ github.ref_name }} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..5963192de44 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,28 @@ +# opentelemetry-java — Development Notes + +## Language + +- AI conversation: always respond in English +- Code, code comments, documentation: always write in US English + +## Before creating a git commit + +Verify successfull builds with: `gradle spotlessApply build` then +run `./gradlew jApiCmp` as the last step before committing **when Kotlin sources changed**. +This updates the API diff files in `docs/apidiffs/current_vs_latest/` and must be included in +the commit. CI enforces that these files are up to date and will fail if they are stale. +YAML, Markdown, and workflow-only changes do not require running `jApiCmp`. + +## Publishing Architecture + +`otel.publish-conventions.gradle.kts` (in `buildSrc/`) defines the Maven publication for all +subprojects via the `maven-publish` convention plugin. + +Sonatype/Maven Central is wired via `nexusPublishing` in the root `build.gradle.kts` — not via +a `repositories {}` block in the convention plugin. Additional publish targets (e.g. GitHub +Packages) must be added inside `publishing { }` in the convention plugin. + +The signing block activates when the `CI` env var is set. Omit `CI` in a workflow to suppress +signing without removing GPG secrets. + +Version is always `1.64.0-SNAPSHOT` while `snapshot = true` in `version.gradle.kts`. diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt new file mode 100644 index 00000000000..137069b8238 --- /dev/null +++ b/LICENSES/Apache-2.0.txt @@ -0,0 +1,73 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md index b4d88aa43ea..0485c9013db 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ [![FOSSA Security Status](https://app.fossa.com/api/projects/custom%2B162%2Fgithub.com%2Fopen-telemetry%2Fopentelemetry-java.svg?type=shield&issueType=security)](https://app.fossa.com/projects/custom%2B162%2Fgithub.com%2Fopen-telemetry%2Fopentelemetry-java?ref=badge_shield&issueType=security) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/open-telemetry/opentelemetry-java/badge)](https://scorecard.dev/viewer/?uri=github.com/open-telemetry/opentelemetry-java) [![Reproducible Builds](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/jvm-repo-rebuild/reproducible-central/master/content/io/opentelemetry/java/badge.json)](https://github.com/jvm-repo-rebuild/reproducible-central/blob/master/content/io/opentelemetry/java/README.md) +[![REUSE status](https://api.reuse.software/badge/github.com/apeirora/opentelemetry-java)](https://api.reuse.software/info/github.com/apeirora/opentelemetry-java) `opentelemetry-java` is the home of the Java implementation of the OpenTelemetry API for recording telemetry, and SDK for managing telemetry recorded by the API. diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 00000000000..6cad12eb65c --- /dev/null +++ b/REUSE.toml @@ -0,0 +1,16 @@ +version = 1 +SPDX-PackageName = "OpenTelemetry Java" +SPDX-PackageSupplier = "opentelemetry-java@googlegroups.com" +SPDX-PackageDownloadLocation = "https://github.com/open-telemetry/opentelemetry-java" + +[[annotations]] +path = "gradle/wrapper/gradle-wrapper.jar" +precedence = "aggregate" +SPDX-FileCopyrightText = "Copyright Gradle, Inc." +SPDX-License-Identifier = "Apache-2.0" + +[[annotations]] +path = "**" +precedence = "aggregate" +SPDX-FileCopyrightText = "Copyright The OpenTelemetry Authors" +SPDX-License-Identifier = "Apache-2.0" diff --git a/all/build.gradle.kts b/all/build.gradle.kts index abe5ee8877b..0879f1f20b6 100644 --- a/all/build.gradle.kts +++ b/all/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { } testImplementation("com.tngtech.archunit:archunit-junit5") + testImplementation("eu.apeirora:audit.log") } // Custom task type for writing artifacts and jars - configuration cache compatible diff --git a/all/src/test/java/io/opentelemetry/all/NoSharedInternalCodeTest.java b/all/src/test/java/io/opentelemetry/all/NoSharedInternalCodeTest.java index 1029239b65a..d700f568f59 100644 --- a/all/src/test/java/io/opentelemetry/all/NoSharedInternalCodeTest.java +++ b/all/src/test/java/io/opentelemetry/all/NoSharedInternalCodeTest.java @@ -35,10 +35,12 @@ class NoSharedInternalCodeTest { "opentelemetry-exporter-common", "opentelemetry-exporter-logging", "opentelemetry-exporter-logging-otlp", + "opentelemetry-exporter-otlp-audit", "opentelemetry-exporter-prometheus", "opentelemetry-extension-trace-propagators", "opentelemetry-opencensus-shim", "opentelemetry-sdk-common", + "opentelemetry-sdk-audit", "opentelemetry-sdk-logs", "opentelemetry-sdk-metrics", "opentelemetry-sdk-profiles", diff --git a/api/all/src/main/java/io/opentelemetry/api/audit/ActorType.java b/api/all/src/main/java/io/opentelemetry/api/audit/ActorType.java new file mode 100644 index 00000000000..0cc53ee33d6 --- /dev/null +++ b/api/all/src/main/java/io/opentelemetry/api/audit/ActorType.java @@ -0,0 +1,23 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.api.audit; + +/** + * Classifies the kind of entity that performed an auditable action. + * + * @see AuditRecordBuilder#setActor(String, ActorType) + */ +public enum ActorType { + + /** A human user, identified by a user account. */ + USER, + + /** An automated service, daemon, or service account. */ + SERVICE, + + /** The operating system or a privileged system component. */ + SYSTEM; +} diff --git a/api/all/src/main/java/io/opentelemetry/api/audit/Audit.java b/api/all/src/main/java/io/opentelemetry/api/audit/Audit.java new file mode 100644 index 00000000000..0261182a97d --- /dev/null +++ b/api/all/src/main/java/io/opentelemetry/api/audit/Audit.java @@ -0,0 +1,62 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.api.audit; + +/** + * Semantic convention attribute names for OpenTelemetry audit records. + * + *

These constants correspond to the {@code audit.*} attributes defined by the audit record data + * model. + */ +public final class Audit { + + /** Unique stable identifier for an audit record. */ + public static final String RECORD_ID = "audit.record.id"; + + /** Identity of the actor that performed the action. */ + public static final String ACTOR_ID = "audit.actor.id"; + + /** Type of the actor that performed the action. */ + public static final String ACTOR_TYPE = "audit.actor.type"; + + /** Verb describing what the actor did. */ + public static final String ACTION = "audit.action"; + + /** Result of the auditable action. */ + public static final String OUTCOME = "audit.outcome"; + + /** Identifier of the target resource acted upon. */ + public static final String TARGET_ID = "audit.target.id"; + + /** Type of the target resource acted upon. */ + public static final String TARGET_TYPE = "audit.target.type"; + + /** Identifier of the source of the auditable action. */ + public static final String SOURCE_ID = "audit.source.id"; + + /** Type of the source of the auditable action. */ + public static final String SOURCE_TYPE = "audit.source.type"; + + /** Base64-encoded cryptographic integrity proof for the record. */ + public static final String INTEGRITY_VALUE = "audit.integrity.value"; + + /** Monotonic sequence number used for hash-chain continuity. */ + public static final String SEQUENCE_NUMBER = "audit.sequence.number"; + + /** Hash of the previous record in the same audit stream. */ + public static final String PREV_HASH = "audit.prev.hash"; + + /** Schema version of the audit payload. */ + public static final String SCHEMA_VERSION = "audit.schema.version"; + + /** Resource attribute naming the integrity algorithm used for emitted audit records. */ + public static final String INTEGRITY_ALGORITHM = "audit.integrity.algorithm"; + + /** Resource attribute referencing the certificate or key used for integrity proofs. */ + public static final String INTEGRITY_CERTIFICATE = "audit.integrity.certificate"; + + private Audit() {} +} diff --git a/api/all/src/main/java/io/opentelemetry/api/audit/AuditDeliveryException.java b/api/all/src/main/java/io/opentelemetry/api/audit/AuditDeliveryException.java new file mode 100644 index 00000000000..8b30f2c9009 --- /dev/null +++ b/api/all/src/main/java/io/opentelemetry/api/audit/AuditDeliveryException.java @@ -0,0 +1,25 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.api.audit; + +/** + * Hard-error thrown by {@link AuditRecordBuilder#emit()} when the audit sink cannot be reached and + * all retries are exhausted. This is an unchecked exception so that audit-logging call sites remain + * clean, but callers SHOULD catch it and escalate the failure through their incident-management + * process. + */ +public final class AuditDeliveryException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public AuditDeliveryException(String message) { + super(message); + } + + public AuditDeliveryException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/api/all/src/main/java/io/opentelemetry/api/audit/AuditLogger.java b/api/all/src/main/java/io/opentelemetry/api/audit/AuditLogger.java new file mode 100644 index 00000000000..e345e56e7f9 --- /dev/null +++ b/api/all/src/main/java/io/opentelemetry/api/audit/AuditLogger.java @@ -0,0 +1,25 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.api.audit; + +import javax.annotation.concurrent.ThreadSafe; + +/** + * The entry point for emitting audit records. + * + *

Obtain an {@link AuditRecordBuilder} via {@link #auditRecordBuilder()}, populate all required + * fields, and call {@link AuditRecordBuilder#emit()} to deliver the record to the audit sink. + * + *

Unlike {@link io.opentelemetry.api.logs.Logger}, this interface does not expose an + * {@code isEnabled} check: audit records are ALWAYS emitted. Dropping audit records is prohibited + * by the audit logging specification. + */ +@ThreadSafe +public interface AuditLogger { + + /** Returns an {@link AuditRecordBuilder} for constructing and emitting an audit record. */ + AuditRecordBuilder auditRecordBuilder(); +} diff --git a/api/all/src/main/java/io/opentelemetry/api/audit/AuditLoggerBuilder.java b/api/all/src/main/java/io/opentelemetry/api/audit/AuditLoggerBuilder.java new file mode 100644 index 00000000000..b2ea7437c76 --- /dev/null +++ b/api/all/src/main/java/io/opentelemetry/api/audit/AuditLoggerBuilder.java @@ -0,0 +1,28 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.api.audit; + +/** + * Builder for creating named {@link AuditLogger} instances. + * + *

The {@code name} (set on the owning {@link AuditProvider}) is stored as a diagnostic label on + * the logger. Unlike {@link io.opentelemetry.api.logs.LoggerBuilder}, the name is NOT mapped to an + * OTLP {@code InstrumentationScope}. + */ +public interface AuditLoggerBuilder { + + /** + * Sets the schema URL to be recorded on emitted {@link AuditRecordBuilder}s for semantic + * convention versioning. + */ + AuditLoggerBuilder setSchemaUrl(String schemaUrl); + + /** Sets the version of the component or library that is emitting audit records. */ + AuditLoggerBuilder setInstrumentationVersion(String instrumentationVersion); + + /** Returns the configured {@link AuditLogger}. */ + AuditLogger build(); +} diff --git a/api/all/src/main/java/io/opentelemetry/api/audit/AuditProvider.java b/api/all/src/main/java/io/opentelemetry/api/audit/AuditProvider.java new file mode 100644 index 00000000000..08093f47f27 --- /dev/null +++ b/api/all/src/main/java/io/opentelemetry/api/audit/AuditProvider.java @@ -0,0 +1,37 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.api.audit; + +import javax.annotation.concurrent.ThreadSafe; + +/** + * The entry point of the Audit Logging API. Provides named {@link AuditLogger} instances. + * + *

The provider is expected to be accessed from a central place. Use {@link + * GlobalAuditProvider#get()} to obtain the globally registered instance, or create an {@link + * AuditProvider} directly via the SDK. + */ +@ThreadSafe +public interface AuditProvider { + + /** + * Gets or creates a named {@link AuditLogger} instance. + * + * @param name A string identifying the component or subsystem emitting audit records (for example + * {@code "com.example.auth"}). MUST NOT be empty. + */ + default AuditLogger get(String name) { + return auditLoggerBuilder(name).build(); + } + + /** + * Creates an {@link AuditLoggerBuilder} for a named {@link AuditLogger}. + * + * @param name A string identifying the component or subsystem emitting audit records. MUST NOT be + * empty. + */ + AuditLoggerBuilder auditLoggerBuilder(String name); +} diff --git a/api/all/src/main/java/io/opentelemetry/api/audit/AuditReceipt.java b/api/all/src/main/java/io/opentelemetry/api/audit/AuditReceipt.java new file mode 100644 index 00000000000..74ec7a4c3b6 --- /dev/null +++ b/api/all/src/main/java/io/opentelemetry/api/audit/AuditReceipt.java @@ -0,0 +1,87 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.api.audit; + +import javax.annotation.concurrent.Immutable; + +/** + * Proof-of-delivery returned by {@link AuditLogger} once the audit sink has persisted the record. + * + *

The {@code recordId} echoes the caller's {@link AuditRecordBuilder#setRecordId(String)}. + * {@code integrityHash} is the SHA-256 of the record as written by the sink. {@code + * sinkTimestampEpochNanos} is the nanosecond UNIX epoch at which the sink persisted the record. + */ +@Immutable +public final class AuditReceipt { + + private final String recordId; + private final String integrityHash; + private final long sinkTimestampEpochNanos; + + private AuditReceipt(String recordId, String integrityHash, long sinkTimestampEpochNanos) { + this.recordId = recordId; + this.integrityHash = integrityHash; + this.sinkTimestampEpochNanos = sinkTimestampEpochNanos; + } + + /** Creates an {@link AuditReceipt} with the given fields. */ + public static AuditReceipt create( + String recordId, String integrityHash, long sinkTimestampEpochNanos) { + return new AuditReceipt(recordId, integrityHash, sinkTimestampEpochNanos); + } + + /** Returns the {@code RecordId} echoed from the corresponding {@link AuditRecordBuilder}. */ + public String recordId() { + return recordId; + } + + /** + * Returns the SHA-256 hex digest of the canonical serialization of the {@code AuditRecord} as + * persisted by the audit sink. + */ + public String integrityHash() { + return integrityHash; + } + + /** Returns the nanosecond UNIX epoch at which the audit sink persisted the record. */ + public long sinkTimestampEpochNanos() { + return sinkTimestampEpochNanos; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof AuditReceipt)) { + return false; + } + AuditReceipt other = (AuditReceipt) obj; + return recordId.equals(other.recordId) + && integrityHash.equals(other.integrityHash) + && sinkTimestampEpochNanos == other.sinkTimestampEpochNanos; + } + + @Override + public int hashCode() { + int result = recordId.hashCode(); + result = 31 * result + integrityHash.hashCode(); + result = 31 * result + Long.hashCode(sinkTimestampEpochNanos); + return result; + } + + @Override + public String toString() { + return "AuditReceipt{" + + "recordId=" + + recordId + + ", integrityHash=" + + integrityHash + + ", sinkTimestampEpochNanos=" + + sinkTimestampEpochNanos + + "}"; + } +} diff --git a/api/all/src/main/java/io/opentelemetry/api/audit/AuditRecordBuilder.java b/api/all/src/main/java/io/opentelemetry/api/audit/AuditRecordBuilder.java new file mode 100644 index 00000000000..88e207d0bae --- /dev/null +++ b/api/all/src/main/java/io/opentelemetry/api/audit/AuditRecordBuilder.java @@ -0,0 +1,150 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.api.audit; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Value; +import java.time.Instant; +import java.util.concurrent.TimeUnit; +import javax.annotation.Nullable; + +/** + * Used to construct and emit {@link AuditReceipt}-returning audit records from an {@link + * AuditLogger}. + * + *

Obtain an {@link AuditLogger#auditRecordBuilder()}, set all required and desired optional + * fields, then call {@link #emit()} which blocks until the audit sink acknowledges the record and + * returns an {@link AuditReceipt} as proof-of-delivery. + * + *

Unlike {@link io.opentelemetry.api.logs.LogRecordBuilder}, {@code emit()} returns a non-void + * {@link AuditReceipt} and MUST NOT silently drop the record. An exception is raised if the sink + * cannot be reached within the configured timeout. + */ +public interface AuditRecordBuilder { + + // ── Required fields ────────────────────────────────────────────────────── + + /** + * Sets the caller-generated unique identifier for this record ({@code audit.record.id}). If not + * set, the SDK MUST generate a UUID v4. The value MUST remain stable across retries of the same + * event. + */ + AuditRecordBuilder setRecordId(String recordId); + + /** + * Sets the epoch timestamp (event time) using the given value and unit. + * + *

This field is required. It represents the time at which the auditable action occurred. + */ + AuditRecordBuilder setTimestamp(long timestamp, TimeUnit unit); + + /** Sets the epoch timestamp (event time) using the given {@link Instant}. */ + AuditRecordBuilder setTimestamp(Instant instant); + + /** + * Sets the semantic name that uniquely identifies the type of audit event ({@code EventName}), + * e.g. {@code "user.login.success"}. MUST be non-empty and stable across releases. + */ + AuditRecordBuilder setEventName(String eventName); + + /** + * Sets the identity of the entity that performed the auditable action ({@code audit.actor.id}). + * + *

If the actor cannot be determined, set to a sentinel such as {@code "anonymous"}. + */ + AuditRecordBuilder setActor(String actorId, ActorType actorType); + + /** + * Sets the verb that describes what the actor did ({@code audit.action}), e.g. {@code "LOGIN"}, + * {@code "READ"}, {@code "DELETE"}. MUST be non-empty and stable across releases. + */ + AuditRecordBuilder setAction(String action); + + /** Sets the result of the auditable action ({@code audit.outcome}). */ + AuditRecordBuilder setOutcome(Outcome outcome); + + // ── Optional fields ─────────────────────────────────────────────────────── + + /** + * Sets the epoch observed-timestamp using the given value and unit. If not set, the SDK MUST set + * this to the wall-clock time at the moment {@link #emit()} is called. + */ + AuditRecordBuilder setObservedTimestamp(long timestamp, TimeUnit unit); + + /** Sets the epoch observed-timestamp using the given {@link Instant}. */ + AuditRecordBuilder setObservedTimestamp(Instant instant); + + /** + * Sets the schema version of the audit payload ({@code audit.schema.version}), e.g. {@code + * "1.0.0"}. + */ + AuditRecordBuilder setSchemaVersion(String schemaVersion); + + /** + * Sets the identifier of the resource acted upon ({@code audit.target.id}), e.g. a file path, + * REST endpoint, or database table name. + */ + AuditRecordBuilder setTarget(String targetId, String targetType); + + /** + * Sets the network address or identifier of the source ({@code audit.source.id}), e.g. {@code + * "203.0.113.42"}. + */ + AuditRecordBuilder setSource(String sourceId, String sourceType); + + /** Sets free-form additional information about the audit event. */ + AuditRecordBuilder setBody(Value body); + + /** Convenience overload of {@link #setBody(Value)} accepting a plain string. */ + default AuditRecordBuilder setBody(String body) { + return setBody(Value.of(body)); + } + + /** + * Sets an attribute on this record. If the record already contains a mapping for the key, the old + * value is replaced. + * + *

Providing a {@code null} value is a no-op and does not remove previously set values. + */ + AuditRecordBuilder addAttribute(AttributeKey key, @Nullable T value); + + /** + * Sets the raw bytes of the cryptographic integrity proof ({@code audit.integrity.value}). The + * value is base64-encoded by the SDK before storing as an attribute. The algorithm used to + * compute the proof (e.g. {@code "ES256"} or {@code "HMAC-SHA256"}) MUST be declared once via + * {@code SdkAuditProviderBuilder.setIntegrityAlgorithm(String)}. + */ + AuditRecordBuilder setIntegrityValue(byte[] integrityValue); + + /** + * Sets the monotonically increasing sequence number ({@code audit.sequence.number}) for + * hash-chain continuity. When set, receivers can detect gaps that indicate lost or deleted + * records. + */ + AuditRecordBuilder setSequenceNo(long sequenceNo); + + /** + * Sets the {@code audit.prev.hash} of the immediately preceding record in the same audit stream, + * enabling hash-chain validation. + */ + AuditRecordBuilder setPrevHash(String prevHash); + + // ── Terminal ────────────────────────────────────────────────────────────── + + /** + * Emits the audit record and blocks until the audit sink acknowledges receipt. + * + *

Returns an {@link AuditReceipt} containing the sink-assigned {@code RecordId}, {@code + * IntegrityHash}, and {@code SinkTimestamp}. + * + *

If the sink cannot be reached within the configured timeout and the retry budget is + * exhausted, this method MUST throw a runtime exception and MUST NOT return silently. + * + * @throws AuditDeliveryException if the audit sink cannot be reached and all retries are + * exhausted + */ + AuditReceipt emit(); +} diff --git a/api/all/src/main/java/io/opentelemetry/api/audit/GlobalAuditProvider.java b/api/all/src/main/java/io/opentelemetry/api/audit/GlobalAuditProvider.java new file mode 100644 index 00000000000..6fd22a311ee --- /dev/null +++ b/api/all/src/main/java/io/opentelemetry/api/audit/GlobalAuditProvider.java @@ -0,0 +1,41 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.api.audit; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Global singleton holder for the process-wide {@link AuditProvider}. + * + *

In most applications there is only one {@link AuditProvider}. {@link #set(AuditProvider)} + * SHOULD be called once, early in the application lifecycle (for example, in the same place where + * the OpenTelemetry SDK is initialised). + */ +public final class GlobalAuditProvider { + + private static final AtomicReference globalProvider = new AtomicReference<>(); + + private GlobalAuditProvider() {} + + /** + * Returns the globally registered {@link AuditProvider}, or throws {@link NullPointerException} + * if {@link #set(AuditProvider)} was not called before. + */ + public static AuditProvider get() { + return Objects.requireNonNull(globalProvider.get()); + } + + /** + * Sets the globally registered {@link AuditProvider}. + * + * @param auditProvider the provider to register; MUST NOT be null + * @throws IllegalArgumentException if {@code auditProvider} is null + */ + public static void set(AuditProvider auditProvider) { + globalProvider.set(auditProvider); + } +} diff --git a/api/all/src/main/java/io/opentelemetry/api/audit/Outcome.java b/api/all/src/main/java/io/opentelemetry/api/audit/Outcome.java new file mode 100644 index 00000000000..7f9e891d8f7 --- /dev/null +++ b/api/all/src/main/java/io/opentelemetry/api/audit/Outcome.java @@ -0,0 +1,23 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.api.audit; + +/** + * The result of an auditable action. + * + * @see AuditRecordBuilder#setOutcome(Outcome) + */ +public enum Outcome { + + /** The action completed successfully. */ + SUCCESS, + + /** The action was attempted but did not complete successfully. */ + FAILURE, + + /** The outcome could not be determined at the time of emission. */ + UNKNOWN +} diff --git a/api/all/src/main/java/io/opentelemetry/api/audit/package-info.java b/api/all/src/main/java/io/opentelemetry/api/audit/package-info.java new file mode 100644 index 00000000000..67bbe297c0b --- /dev/null +++ b/api/all/src/main/java/io/opentelemetry/api/audit/package-info.java @@ -0,0 +1,10 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** OpenTelemetry Audit Logging API. */ +@ParametersAreNonnullByDefault +package io.opentelemetry.api.audit; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/api/build.gradle.kts b/api/build.gradle.kts index facf4cf9506..6a2df27ce7e 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -1,6 +1,6 @@ subprojects { // Workaround https://github.com/gradle/gradle/issues/847 - group = "io.opentelemetry.api" + group = "eu.apeirora.opentelemetry.api" val proj = this plugins.withId("java") { configure { diff --git a/bom-alpha/build.gradle.kts b/bom-alpha/build.gradle.kts index df363b28fd1..69017589d79 100644 --- a/bom-alpha/build.gradle.kts +++ b/bom-alpha/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } description = "OpenTelemetry Bill of Materials (Alpha)" -group = "io.opentelemetry" +group = "eu.apeirora.opentelemetry" base.archivesName.set("opentelemetry-bom-alpha") otelBom.projectFilter.set { it.findProperty("otel.release") == "alpha" } diff --git a/bom/build.gradle.kts b/bom/build.gradle.kts index 50d8dc950a1..e92d92eae3f 100644 --- a/bom/build.gradle.kts +++ b/bom/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } description = "OpenTelemetry Bill of Materials" -group = "io.opentelemetry" +group = "eu.apeirora.opentelemetry" base.archivesName.set("opentelemetry-bom") otelBom.projectFilter.set { !it.hasProperty("otel.release") } diff --git a/build.gradle.kts b/build.gradle.kts index b2953d90939..143c7b99897 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,7 +9,7 @@ plugins { apply(from = "version.gradle.kts") nexusPublishing { - packageGroup.set("io.opentelemetry") + packageGroup.set("eu.apeirora.opentelemetry") repositories { // see https://central.sonatype.org/publish/publish-portal-ossrh-staging-api/#configuration @@ -39,7 +39,7 @@ tasks.register("generateBuildSubstitutions") { } subprojects { - group = "io.opentelemetry" + group = "eu.apeirora.opentelemetry" } tasks { diff --git a/buildSrc/src/main/kotlin/io/opentelemetry/gradle/OtelVersionClassPlugin.kt b/buildSrc/src/main/kotlin/io/opentelemetry/gradle/OtelVersionClassPlugin.kt index fabcd943840..f5b40158d6a 100644 --- a/buildSrc/src/main/kotlin/io/opentelemetry/gradle/OtelVersionClassPlugin.kt +++ b/buildSrc/src/main/kotlin/io/opentelemetry/gradle/OtelVersionClassPlugin.kt @@ -26,8 +26,11 @@ class OtelVersionClassPlugin : Plugin { val outDir = project.layout.buildDirectory.dir("generated/sources/version/java/main") + // Remap the Maven group to the Java package root: eu.apeirora.opentelemetry → io.opentelemetry + val javaGroup = "${project.group}".replace("eu.apeirora.opentelemetry", "io.opentelemetry") + project.tasks.register("generateOtelVersionClass", GenerateOtelVersionClassTask::class.java) { - projectGroup.set("${project.group}") + projectGroup.set(javaGroup) projectName.set(project.name) projectVersion.set("${project.version}") outputDirectory.set(outDir) diff --git a/buildSrc/src/main/kotlin/otel.publish-conventions.gradle.kts b/buildSrc/src/main/kotlin/otel.publish-conventions.gradle.kts index 0a6a100fe15..ea18c7c2d7f 100644 --- a/buildSrc/src/main/kotlin/otel.publish-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/otel.publish-conventions.gradle.kts @@ -8,7 +8,7 @@ plugins { publishing { publications { register("mavenPublication") { - groupId = "io.opentelemetry" + groupId = "eu.apeirora.opentelemetry" afterEvaluate { // not available until evaluated. artifactId = base.archivesName.get() @@ -66,9 +66,28 @@ publishing { } } } + + // Publish to GitHub Packages only for SNAPSHOT builds. + // Activated when GITHUB_TOKEN is set and the version ends with -SNAPSHOT. + // Local builds and the Sonatype release workflow are unaffected. + val githubToken = System.getenv("GITHUB_TOKEN") + if (githubToken != null && project.version.toString().endsWith("-SNAPSHOT")) { + repositories { + maven { + name = "githubPackages" + url = uri("https://maven.pkg.github.com/apeirora/opentelemetry-java") + credentials { + username = System.getenv("GITHUB_ACTOR") ?: "apeirora-bot" + password = githubToken + } + } + } + } } -if (System.getenv("CI") != null) { +// Sign only when GPG credentials are explicitly provided. +// GitHub Actions sets CI=true unconditionally, so gating on CI would break snapshot-only workflows. +if (System.getenv("GPG_PRIVATE_KEY") != null) { signing { useInMemoryPgpKeys(System.getenv("GPG_PRIVATE_KEY"), System.getenv("GPG_PASSWORD")) sign(publishing.publications["mavenPublication"]) diff --git a/custom-checks/src/test/java/io/opentelemetry/gradle/customchecks/OtelImplJavadocTest.java b/custom-checks/src/test/java/io/opentelemetry/gradle/customchecks/OtelImplJavadocTest.java index ad294d61b2c..9b9126a1506 100644 --- a/custom-checks/src/test/java/io/opentelemetry/gradle/customchecks/OtelImplJavadocTest.java +++ b/custom-checks/src/test/java/io/opentelemetry/gradle/customchecks/OtelImplJavadocTest.java @@ -15,10 +15,12 @@ void positiveCases() { CompilationTestHelper.newInstance(OtelImplJavadoc.class, OtelImplJavadocTest.class) .addSourceLines( "impl/ImplJavadocPositiveCases.java", + // REUSE-IgnoreStart "/*", " * Copyright The OpenTelemetry Authors", " * SPDX-License-Identifier: Apache-2.0", " */", + // REUSE-IgnoreEnd "package io.opentelemetry.gradle.customchecks.impl;", "// BUG: Diagnostic contains: missing the required javadoc disclaimer", "public class ImplJavadocPositiveCases {", @@ -36,10 +38,12 @@ void negativeCases() { CompilationTestHelper.newInstance(OtelImplJavadoc.class, OtelImplJavadocTest.class) .addSourceLines( "impl/ImplJavadocNegativeCases.java", + // REUSE-IgnoreStart "/*", " * Copyright The OpenTelemetry Authors", " * SPDX-License-Identifier: Apache-2.0", " */", + // REUSE-IgnoreEnd "package io.opentelemetry.gradle.customchecks.impl;", "/**", " * This class is not intended for use by application developers. Its API is stable and", @@ -62,10 +66,12 @@ void nonImplPackageIgnored() { CompilationTestHelper.newInstance(OtelImplJavadoc.class, OtelImplJavadocTest.class) .addSourceLines( "other/NonImplPackageCases.java", + // REUSE-IgnoreStart "/*", " * Copyright The OpenTelemetry Authors", " * SPDX-License-Identifier: Apache-2.0", " */", + // REUSE-IgnoreEnd "package io.opentelemetry.gradle.customchecks.other;", "public class NonImplPackageCases {", " public static class One {}", diff --git a/custom-checks/src/test/java/io/opentelemetry/gradle/customchecks/OtelInternalJavadocTest.java b/custom-checks/src/test/java/io/opentelemetry/gradle/customchecks/OtelInternalJavadocTest.java index 34fbc46b6b6..777b4dd8c06 100644 --- a/custom-checks/src/test/java/io/opentelemetry/gradle/customchecks/OtelInternalJavadocTest.java +++ b/custom-checks/src/test/java/io/opentelemetry/gradle/customchecks/OtelInternalJavadocTest.java @@ -15,10 +15,12 @@ void positiveCases() { CompilationTestHelper.newInstance(OtelInternalJavadoc.class, OtelInternalJavadocTest.class) .addSourceLines( "internal/InternalJavadocPositiveCases.java", + // REUSE-IgnoreStart "/*", " * Copyright The OpenTelemetry Authors", " * SPDX-License-Identifier: Apache-2.0", " */", + // REUSE-IgnoreEnd "package io.opentelemetry.gradle.customchecks.internal;", "// BUG: Diagnostic contains: doesn't end with any of the applicable javadoc disclaimers", "public class InternalJavadocPositiveCases {", @@ -36,10 +38,12 @@ void negativeCases() { CompilationTestHelper.newInstance(OtelInternalJavadoc.class, OtelInternalJavadocTest.class) .addSourceLines( "internal/InternalJavadocNegativeCases.java", + // REUSE-IgnoreStart "/*", " * Copyright The OpenTelemetry Authors", " * SPDX-License-Identifier: Apache-2.0", " */", + // REUSE-IgnoreEnd "package io.opentelemetry.gradle.customchecks.internal;", "/**", " * This class is internal and is hence not for public use. Its APIs are unstable and can change at", diff --git a/dependencyManagement/build.gradle.kts b/dependencyManagement/build.gradle.kts index 4b205859618..3743aec4fbd 100644 --- a/dependencyManagement/build.gradle.kts +++ b/dependencyManagement/build.gradle.kts @@ -17,6 +17,7 @@ val slf4jVersion = "2.0.18" val opencensusVersion = "0.31.1" val prometheusServerVersion = "1.8.0" val armeriaVersion = "1.40.0" +val apeiroraAuditLogVersion = "0.0.1-SNAPSHOT" val junitVersion = "5.14.4" val junitPlatformVersion = "1.14.4" val okhttpVersion = "5.4.0" @@ -53,6 +54,8 @@ val DEPENDENCIES = listOf( "com.linecorp.armeria:armeria-grpc-protocol:${armeriaVersion}", "com.linecorp.armeria:armeria-junit5:${armeriaVersion}", + "eu.apeirora:audit.log:${apeiroraAuditLogVersion}", + "com.google.auto.value:auto-value:${autoValueVersion}", "com.google.auto.value:auto-value-annotations:${autoValueVersion}", "com.google.errorprone:error_prone_annotations:${errorProneVersion}", diff --git a/docs/apidiffs/current_vs_latest/opentelemetry-api.txt b/docs/apidiffs/current_vs_latest/opentelemetry-api.txt index d55eea5331d..439bde26c03 100644 --- a/docs/apidiffs/current_vs_latest/opentelemetry-api.txt +++ b/docs/apidiffs/current_vs_latest/opentelemetry-api.txt @@ -1,2 +1,100 @@ Comparing source compatibility of opentelemetry-api-1.64.0-SNAPSHOT.jar against opentelemetry-api-1.63.0.jar -No changes. \ No newline at end of file ++++ NEW ENUM: PUBLIC(+) FINAL(+) io.opentelemetry.api.audit.ActorType (compatible) + +++ CLASS FILE FORMAT VERSION: 52.0 <- n.a. + +++ NEW INTERFACE: java.lang.constant.Constable + +++ NEW INTERFACE: java.lang.Comparable + +++ NEW INTERFACE: java.io.Serializable + +++ NEW SUPERCLASS: java.lang.Enum + +++ NEW FIELD: PUBLIC(+) STATIC(+) FINAL(+) io.opentelemetry.api.audit.ActorType SYSTEM + +++ NEW FIELD: PUBLIC(+) STATIC(+) FINAL(+) io.opentelemetry.api.audit.ActorType SERVICE + +++ NEW FIELD: PUBLIC(+) STATIC(+) FINAL(+) io.opentelemetry.api.audit.ActorType USER + +++ NEW METHOD: PUBLIC(+) STATIC(+) io.opentelemetry.api.audit.ActorType valueOf(java.lang.String) + +++ NEW METHOD: PUBLIC(+) STATIC(+) io.opentelemetry.api.audit.ActorType[] values() ++++ NEW CLASS: PUBLIC(+) FINAL(+) io.opentelemetry.api.audit.Audit (not serializable) + +++ CLASS FILE FORMAT VERSION: 52.0 <- n.a. + +++ NEW SUPERCLASS: java.lang.Object + +++ NEW FIELD: PUBLIC(+) STATIC(+) FINAL(+) java.lang.String ACTOR_ID + +++ NEW FIELD: PUBLIC(+) STATIC(+) FINAL(+) java.lang.String SOURCE_ID + +++ NEW FIELD: PUBLIC(+) STATIC(+) FINAL(+) java.lang.String OUTCOME + +++ NEW FIELD: PUBLIC(+) STATIC(+) FINAL(+) java.lang.String INTEGRITY_CERTIFICATE + +++ NEW FIELD: PUBLIC(+) STATIC(+) FINAL(+) java.lang.String ACTOR_TYPE + +++ NEW FIELD: PUBLIC(+) STATIC(+) FINAL(+) java.lang.String SCHEMA_VERSION + +++ NEW FIELD: PUBLIC(+) STATIC(+) FINAL(+) java.lang.String SEQUENCE_NUMBER + +++ NEW FIELD: PUBLIC(+) STATIC(+) FINAL(+) java.lang.String TARGET_ID + +++ NEW FIELD: PUBLIC(+) STATIC(+) FINAL(+) java.lang.String INTEGRITY_ALGORITHM + +++ NEW FIELD: PUBLIC(+) STATIC(+) FINAL(+) java.lang.String ACTION + +++ NEW FIELD: PUBLIC(+) STATIC(+) FINAL(+) java.lang.String PREV_HASH + +++ NEW FIELD: PUBLIC(+) STATIC(+) FINAL(+) java.lang.String SOURCE_TYPE + +++ NEW FIELD: PUBLIC(+) STATIC(+) FINAL(+) java.lang.String RECORD_ID + +++ NEW FIELD: PUBLIC(+) STATIC(+) FINAL(+) java.lang.String TARGET_TYPE + +++ NEW FIELD: PUBLIC(+) STATIC(+) FINAL(+) java.lang.String INTEGRITY_VALUE ++++ NEW CLASS: PUBLIC(+) FINAL(+) io.opentelemetry.api.audit.AuditDeliveryException (compatible) + +++ CLASS FILE FORMAT VERSION: 52.0 <- n.a. + +++ NEW INTERFACE: java.io.Serializable + +++ NEW SUPERCLASS: java.lang.RuntimeException + +++ NEW CONSTRUCTOR: PUBLIC(+) AuditDeliveryException(java.lang.String) + +++ NEW CONSTRUCTOR: PUBLIC(+) AuditDeliveryException(java.lang.String, java.lang.Throwable) ++++ NEW INTERFACE: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditLogger (not serializable) + +++ CLASS FILE FORMAT VERSION: 52.0 <- n.a. + +++ NEW SUPERCLASS: java.lang.Object + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditRecordBuilder auditRecordBuilder() ++++ NEW INTERFACE: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditLoggerBuilder (not serializable) + +++ CLASS FILE FORMAT VERSION: 52.0 <- n.a. + +++ NEW SUPERCLASS: java.lang.Object + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditLogger build() + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditLoggerBuilder setInstrumentationVersion(java.lang.String) + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditLoggerBuilder setSchemaUrl(java.lang.String) ++++ NEW INTERFACE: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditProvider (not serializable) + +++ CLASS FILE FORMAT VERSION: 52.0 <- n.a. + +++ NEW SUPERCLASS: java.lang.Object + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditLoggerBuilder auditLoggerBuilder(java.lang.String) + +++ NEW METHOD: PUBLIC(+) io.opentelemetry.api.audit.AuditLogger get(java.lang.String) ++++ NEW CLASS: PUBLIC(+) FINAL(+) io.opentelemetry.api.audit.AuditReceipt (not serializable) + +++ CLASS FILE FORMAT VERSION: 52.0 <- n.a. + +++ NEW SUPERCLASS: java.lang.Object + +++ NEW METHOD: PUBLIC(+) STATIC(+) io.opentelemetry.api.audit.AuditReceipt create(java.lang.String, java.lang.String, long) + +++ NEW METHOD: PUBLIC(+) boolean equals(java.lang.Object) + +++ NEW METHOD: PUBLIC(+) int hashCode() + +++ NEW METHOD: PUBLIC(+) java.lang.String integrityHash() + +++ NEW METHOD: PUBLIC(+) java.lang.String recordId() + +++ NEW METHOD: PUBLIC(+) long sinkTimestampEpochNanos() + +++ NEW METHOD: PUBLIC(+) java.lang.String toString() ++++ NEW INTERFACE: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditRecordBuilder (not serializable) + +++ CLASS FILE FORMAT VERSION: 52.0 <- n.a. + +++ NEW SUPERCLASS: java.lang.Object + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditRecordBuilder addAttribute(io.opentelemetry.api.common.AttributeKey, java.lang.Object) + GENERIC TEMPLATES: +++ T:java.lang.Object + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditReceipt emit() + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditRecordBuilder setAction(java.lang.String) + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditRecordBuilder setActor(java.lang.String, io.opentelemetry.api.audit.ActorType) + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditRecordBuilder setBody(io.opentelemetry.api.common.Value) + +++ NEW METHOD: PUBLIC(+) io.opentelemetry.api.audit.AuditRecordBuilder setBody(java.lang.String) + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditRecordBuilder setEventName(java.lang.String) + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditRecordBuilder setIntegrityValue(byte[]) + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditRecordBuilder setObservedTimestamp(long, java.util.concurrent.TimeUnit) + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditRecordBuilder setObservedTimestamp(java.time.Instant) + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditRecordBuilder setOutcome(io.opentelemetry.api.audit.Outcome) + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditRecordBuilder setPrevHash(java.lang.String) + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditRecordBuilder setRecordId(java.lang.String) + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditRecordBuilder setSchemaVersion(java.lang.String) + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditRecordBuilder setSequenceNo(long) + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditRecordBuilder setSource(java.lang.String, java.lang.String) + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditRecordBuilder setTarget(java.lang.String, java.lang.String) + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditRecordBuilder setTimestamp(long, java.util.concurrent.TimeUnit) + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.audit.AuditRecordBuilder setTimestamp(java.time.Instant) ++++ NEW CLASS: PUBLIC(+) FINAL(+) io.opentelemetry.api.audit.GlobalAuditProvider (not serializable) + +++ CLASS FILE FORMAT VERSION: 52.0 <- n.a. + +++ NEW SUPERCLASS: java.lang.Object + +++ NEW METHOD: PUBLIC(+) STATIC(+) io.opentelemetry.api.audit.AuditProvider get() + +++ NEW METHOD: PUBLIC(+) STATIC(+) void set(io.opentelemetry.api.audit.AuditProvider) ++++ NEW ENUM: PUBLIC(+) FINAL(+) io.opentelemetry.api.audit.Outcome (compatible) + +++ CLASS FILE FORMAT VERSION: 52.0 <- n.a. + +++ NEW INTERFACE: java.lang.constant.Constable + +++ NEW INTERFACE: java.lang.Comparable + +++ NEW INTERFACE: java.io.Serializable + +++ NEW SUPERCLASS: java.lang.Enum + +++ NEW FIELD: PUBLIC(+) STATIC(+) FINAL(+) io.opentelemetry.api.audit.Outcome SUCCESS + +++ NEW FIELD: PUBLIC(+) STATIC(+) FINAL(+) io.opentelemetry.api.audit.Outcome UNKNOWN + +++ NEW FIELD: PUBLIC(+) STATIC(+) FINAL(+) io.opentelemetry.api.audit.Outcome FAILURE + +++ NEW METHOD: PUBLIC(+) STATIC(+) io.opentelemetry.api.audit.Outcome valueOf(java.lang.String) + +++ NEW METHOD: PUBLIC(+) STATIC(+) io.opentelemetry.api.audit.Outcome[] values() diff --git a/docs/apidiffs/current_vs_latest/opentelemetry-sdk-extension-autoconfigure-spi.txt b/docs/apidiffs/current_vs_latest/opentelemetry-sdk-extension-autoconfigure-spi.txt index bc9b0beb5fc..e75f666a23a 100644 --- a/docs/apidiffs/current_vs_latest/opentelemetry-sdk-extension-autoconfigure-spi.txt +++ b/docs/apidiffs/current_vs_latest/opentelemetry-sdk-extension-autoconfigure-spi.txt @@ -1,2 +1,6 @@ Comparing source compatibility of opentelemetry-sdk-extension-autoconfigure-spi-1.64.0-SNAPSHOT.jar against opentelemetry-sdk-extension-autoconfigure-spi-1.63.0.jar -No changes. \ No newline at end of file ++++ NEW INTERFACE: PUBLIC(+) ABSTRACT(+) io.opentelemetry.sdk.autoconfigure.spi.audit.ConfigurableAuditRecordExporterProvider (not serializable) + +++ CLASS FILE FORMAT VERSION: 52.0 <- n.a. + +++ NEW SUPERCLASS: java.lang.Object + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) io.opentelemetry.sdk.audit.AuditRecordExporter createExporter(io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties) + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) java.lang.String getName() diff --git a/docs/github-packages-snapshot.md b/docs/github-packages-snapshot.md new file mode 100644 index 00000000000..15301d440be --- /dev/null +++ b/docs/github-packages-snapshot.md @@ -0,0 +1,126 @@ +# Consuming SNAPSHOT Artifacts from GitHub Packages + +SNAPSHOT builds of this project are published to GitHub Packages on every push +to `main` and `auditing`. The group ID is `eu.apeirora.opentelemetry` and the +version is `1.64.0-SNAPSHOT` (or whatever `version.gradle.kts` currently sets). + +## Prerequisites + +You need a GitHub personal access token (PAT) with the `read:packages` scope. +Create one at https://github.com/settings/tokens. + +## Maven + +**`~/.m2/settings.xml`** — add a server entry: + +```xml + + + + github-apeirora + YOUR_GITHUB_USERNAME + YOUR_GITHUB_TOKEN + + + +``` + +**`pom.xml`** — add the repository and declare the dependency: + +```xml + + + github-apeirora + https://maven.pkg.github.com/apeirora/opentelemetry-java + true + false + + + + + + eu.apeirora.opentelemetry + opentelemetry-api + 1.64.0-SNAPSHOT + + +``` + +Replace `opentelemetry-api` with the artifact you need. All published artifact +IDs follow the pattern `opentelemetry-`. + +## Gradle (Kotlin DSL) + +**`~/.gradle/gradle.properties`** — store credentials outside the project: + +```properties +gpr.user=YOUR_GITHUB_USERNAME +gpr.key=YOUR_GITHUB_TOKEN +``` + +**`build.gradle.kts`** — add the repository: + +```kotlin +repositories { + maven { + name = "GitHubApeirora" + url = uri("https://maven.pkg.github.com/apeirora/opentelemetry-java") + credentials { + username = providers.gradleProperty("gpr.user") + .orElse(providers.environmentVariable("GITHUB_ACTOR")).get() + password = providers.gradleProperty("gpr.key") + .orElse(providers.environmentVariable("GITHUB_TOKEN")).get() + } + } +} + +dependencies { + implementation("eu.apeirora.opentelemetry:opentelemetry-api:1.64.0-SNAPSHOT") +} +``` + +## Using in CI (GitHub Actions) + +When your consumer project itself runs on GitHub Actions, `GITHUB_TOKEN` is +automatically available — no PAT needed. + +### Gradle + +The repository block above already reads `GITHUB_TOKEN` and `GITHUB_ACTOR` from +the environment, so no extra configuration is required: + +```yaml +- name: Build + run: ./gradlew build + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_ACTOR: ${{ github.actor }} +``` + +### Maven + +Maven does not substitute environment variables in `settings.xml` unless you use +`${env.VAR}` syntax. Create a CI-specific settings file (e.g., +`.github/maven-settings.xml`) in your consumer project: + +```xml + + + + github-apeirora + ${env.GITHUB_ACTOR} + ${env.GITHUB_TOKEN} + + + +``` + +Then reference it in your workflow step: + +```yaml +- name: Build + run: mvn verify --settings .github/maven-settings.xml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_ACTOR: ${{ github.actor }} +``` diff --git a/exporters/build.gradle.kts b/exporters/build.gradle.kts index 9e409da647e..99f78b274bd 100644 --- a/exporters/build.gradle.kts +++ b/exporters/build.gradle.kts @@ -1,6 +1,6 @@ subprojects { // https://github.com/gradle/gradle/issues/847 - group = "io.opentelemetry.exporters" + group = "eu.apeirora.opentelemetry.exporters" val proj = this plugins.withId("java") { configure { diff --git a/exporters/otlp/audit/build.gradle.kts b/exporters/otlp/audit/build.gradle.kts new file mode 100644 index 00000000000..ddd1ee333de --- /dev/null +++ b/exporters/otlp/audit/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id("otel.java-conventions") + id("otel.publish-conventions") + id("otel.animalsniffer-conventions") +} + +description = "OpenTelemetry OTLP Audit Exporter" +otelJava.moduleName.set("io.opentelemetry.exporter.otlp.audit") + +dependencies { + api(project(":sdk:audit")) + api(project(":sdk:logs")) + implementation(project(":exporters:otlp:all")) + implementation(project(":exporters:otlp:common")) + implementation(project(":exporters:sender:okhttp")) + + testImplementation(project(":sdk:audit")) + testImplementation(project(":sdk:logs")) +} diff --git a/exporters/otlp/audit/gradle.properties b/exporters/otlp/audit/gradle.properties new file mode 100644 index 00000000000..4476ae57e31 --- /dev/null +++ b/exporters/otlp/audit/gradle.properties @@ -0,0 +1 @@ +otel.release=alpha diff --git a/exporters/otlp/audit/src/main/java/io/opentelemetry/exporter/otlp/http/audit/AuditLogRecordDataAdapter.java b/exporters/otlp/audit/src/main/java/io/opentelemetry/exporter/otlp/http/audit/AuditLogRecordDataAdapter.java new file mode 100644 index 00000000000..04fe7b16239 --- /dev/null +++ b/exporters/otlp/audit/src/main/java/io/opentelemetry/exporter/otlp/http/audit/AuditLogRecordDataAdapter.java @@ -0,0 +1,187 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.exporter.otlp.http.audit; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.api.common.Value; +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.sdk.audit.AuditRecordData; +import io.opentelemetry.sdk.common.InstrumentationScopeInfo; +import io.opentelemetry.sdk.logs.data.Body; +import io.opentelemetry.sdk.logs.data.LogRecordData; +import io.opentelemetry.sdk.resources.Resource; +import java.util.Base64; +import java.util.Locale; +import javax.annotation.Nullable; + +/** + * Adapts an {@link AuditRecordData} to the {@link LogRecordData} interface so that the existing + * OTLP log marshaling infrastructure can serialize audit records to the {@code + * ExportLogsServiceRequest} protobuf message. + * + *

Mappings per the Audit Logging specification: + * + *

+ */ +final class AuditLogRecordDataAdapter implements LogRecordData { + + // Mandatory attributes – spec section: Audit Semantic Attributes + private static final String ATTR_RECORD_ID = "audit.record.id"; + private static final String ATTR_ACTOR_ID = "audit.actor.id"; + private static final String ATTR_ACTOR_TYPE = "audit.actor.type"; + private static final String ATTR_ACTION = "audit.action"; + private static final String ATTR_OUTCOME = "audit.outcome"; + + // Optional attributes + private static final String ATTR_TARGET_ID = "audit.target.id"; + private static final String ATTR_TARGET_TYPE = "audit.target.type"; + private static final String ATTR_SOURCE_ID = "audit.source.id"; + private static final String ATTR_SOURCE_TYPE = "audit.source.type"; + private static final String ATTR_INTEGRITY_VALUE = "audit.integrity.value"; + private static final String ATTR_SEQUENCE_NUMBER = "audit.sequence.number"; + private static final String ATTR_PREV_HASH = "audit.prev.hash"; + private static final String ATTR_SCHEMA_VERSION = "audit.schema.version"; + + private final AuditRecordData audit; + private final Attributes mergedAttributes; + + AuditLogRecordDataAdapter(AuditRecordData audit) { + this.audit = audit; + this.mergedAttributes = buildAttributes(audit); + } + + private static Attributes buildAttributes(AuditRecordData a) { + AttributesBuilder b = Attributes.builder(); + + // Mandatory audit fields – spec-defined attribute keys and lowercase values + b.put(AttributeKey.stringKey(ATTR_RECORD_ID), a.getRecordId()); + b.put(AttributeKey.stringKey(ATTR_ACTOR_ID), a.getActorId()); + b.put( + AttributeKey.stringKey(ATTR_ACTOR_TYPE), a.getActorType().name().toLowerCase(Locale.ROOT)); + b.put(AttributeKey.stringKey(ATTR_ACTION), a.getAction()); + b.put(AttributeKey.stringKey(ATTR_OUTCOME), a.getOutcome().name().toLowerCase(Locale.ROOT)); + + // Optional target attributes + if (a.getTargetId() != null) { + b.put(AttributeKey.stringKey(ATTR_TARGET_ID), a.getTargetId()); + } + if (a.getTargetType() != null) { + b.put(AttributeKey.stringKey(ATTR_TARGET_TYPE), a.getTargetType()); + } + + // Optional source attributes + if (a.getSourceId() != null) { + b.put(AttributeKey.stringKey(ATTR_SOURCE_ID), a.getSourceId()); + } + if (a.getSourceType() != null) { + b.put(AttributeKey.stringKey(ATTR_SOURCE_TYPE), a.getSourceType()); + } + + // Integrity value: base64-encode the proof into audit.integrity.value + byte[] integrityBytes = a.getIntegrityValue(); + if (integrityBytes != null) { + b.put( + AttributeKey.stringKey(ATTR_INTEGRITY_VALUE), + Base64.getEncoder().encodeToString(integrityBytes)); + } + + // Ordering attributes + if (a.getSequenceNo() != 0) { + b.put(AttributeKey.longKey(ATTR_SEQUENCE_NUMBER), a.getSequenceNo()); + } + if (a.getPrevHash() != null) { + b.put(AttributeKey.stringKey(ATTR_PREV_HASH), a.getPrevHash()); + } + + // Schema version + if (a.getSchemaVersion() != null) { + b.put(AttributeKey.stringKey(ATTR_SCHEMA_VERSION), a.getSchemaVersion()); + } + + // User-supplied attributes (merged last so they can override if needed) + a.getAttributes() + .forEach( + (key, value) -> { + @SuppressWarnings("unchecked") + AttributeKey castKey = (AttributeKey) key; + b.put(castKey, value); + }); + + return b.build(); + } + + @Override + public Resource getResource() { + return audit.getResource(); + } + + /** Audit records do not use instrumentation scope; always returns empty. */ + @Override + public InstrumentationScopeInfo getInstrumentationScopeInfo() { + return InstrumentationScopeInfo.empty(); + } + + @Override + public long getTimestampEpochNanos() { + return audit.getTimestampEpochNanos(); + } + + @Override + public long getObservedTimestampEpochNanos() { + return audit.getObservedTimestampEpochNanos(); + } + + @Override + public SpanContext getSpanContext() { + return SpanContext.getInvalid(); + } + + /** Audit records do not use severity. */ + @Override + public Severity getSeverity() { + return Severity.UNDEFINED_SEVERITY_NUMBER; + } + + @Override + @Nullable + public String getSeverityText() { + return null; + } + + @Override + @Deprecated + public Body getBody() { + return audit.getBody() != null ? Body.string(audit.getBody().asString()) : Body.empty(); + } + + @Override + @Nullable + public Value getBodyValue() { + return audit.getBodyValue(); + } + + @Override + public Attributes getAttributes() { + return mergedAttributes; + } + + @Override + public int getTotalAttributeCount() { + return mergedAttributes.size(); + } + + @Override + public String getEventName() { + return audit.getEventName(); + } +} diff --git a/exporters/otlp/audit/src/main/java/io/opentelemetry/exporter/otlp/http/audit/OtlpHttpAuditRecordExporter.java b/exporters/otlp/audit/src/main/java/io/opentelemetry/exporter/otlp/http/audit/OtlpHttpAuditRecordExporter.java new file mode 100644 index 00000000000..f6174ebfce1 --- /dev/null +++ b/exporters/otlp/audit/src/main/java/io/opentelemetry/exporter/otlp/http/audit/OtlpHttpAuditRecordExporter.java @@ -0,0 +1,148 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.exporter.otlp.http.audit; + +import io.opentelemetry.api.audit.AuditDeliveryException; +import io.opentelemetry.api.audit.AuditReceipt; +import io.opentelemetry.exporter.internal.otlp.logs.LogsRequestMarshaler; +import io.opentelemetry.exporter.otlp.internal.HttpExporter; +import io.opentelemetry.exporter.otlp.internal.HttpExporterBuilder; +import io.opentelemetry.sdk.audit.AuditExportResult; +import io.opentelemetry.sdk.audit.AuditRecordData; +import io.opentelemetry.sdk.audit.AuditRecordExporter; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.logs.data.LogRecordData; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.StringJoiner; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import javax.annotation.concurrent.ThreadSafe; + +/** + * Exports {@link AuditRecordData}s using OTLP/HTTP to the dedicated {@code /v1/audit} endpoint. + * + *

Audit records are serialized as OTLP {@code LogRecord} protobuf messages (reusing the {@code + * ExportLogsServiceRequest} envelope) with mandatory audit fields stored as attributes. + * + *

The OTLP receiver MUST NOT respond with {@code partial_success}; any partial-success response + * is treated as a hard failure and all records in the batch are retained for retry. + * + *

Create via {@link #builder()} or {@link #getDefault()}. + */ +@ThreadSafe +public final class OtlpHttpAuditRecordExporter implements AuditRecordExporter { + + static final String DEFAULT_ENDPOINT = "http://localhost:4318/v1/audit"; + + private final HttpExporterBuilder builder; + private final HttpExporter delegate; + + OtlpHttpAuditRecordExporter(HttpExporterBuilder builder, HttpExporter delegate) { + this.builder = builder; + this.delegate = delegate; + } + + /** Returns a new {@link OtlpHttpAuditRecordExporter} with default configuration. */ + public static OtlpHttpAuditRecordExporter getDefault() { + return builder().build(); + } + + /** Returns a new {@link OtlpHttpAuditRecordExporterBuilder}. */ + public static OtlpHttpAuditRecordExporterBuilder builder() { + return new OtlpHttpAuditRecordExporterBuilder(); + } + + /** + * Exports the given audit records to the configured OTLP {@code /v1/audit} endpoint. + * + *

Audit records are adapted to OTLP {@code LogRecord}s via {@link AuditLogRecordDataAdapter}. + * The {@code InstrumentationScope} is left empty and {@code SeverityNumber} is unset per the + * audit logging specification. + * + *

Returns synthetic {@link AuditReceipt}s on success. The {@code IntegrityHash} field is empty + * in this implementation; a future OTLP response extension will carry the sink-computed hash. + * + *

Any {@code partial_success} response is treated as a hard failure (all records are returned + * in the failure result for retry). + */ + @Override + public AuditExportResult export(Collection records) { + if (records.isEmpty()) { + return AuditExportResult.success(Collections.emptyList()); + } + + // Adapt AuditRecordData → LogRecordData for marshaling + List adapted = new ArrayList<>(records.size()); + for (AuditRecordData record : records) { + adapted.add(new AuditLogRecordDataAdapter(record)); + } + + // Serialize as ExportLogsServiceRequest and send to /v1/audit + LogsRequestMarshaler marshaler = LogsRequestMarshaler.create(adapted); + AtomicReference failureCause = new AtomicReference<>(); + AtomicBoolean succeeded = new AtomicBoolean(false); + CountDownLatch latch = new CountDownLatch(1); + + CompletableResultCode result = delegate.export(marshaler, adapted.size()); + result.whenComplete( + () -> { + if (result.isSuccess()) { + succeeded.set(true); + } else { + failureCause.set(result.getFailureThrowable()); + } + latch.countDown(); + }); + + try { + // Block until export completes (audit records must be acknowledged) + latch.await(30, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return AuditExportResult.failure(e); + } + + if (latch.getCount() > 0) { + return AuditExportResult.failure( + new AuditDeliveryException( + "OTLP export timed out waiting for /v1/audit acknowledgement")); + } + + if (!succeeded.get()) { + Throwable cause = failureCause.get(); + return cause != null ? AuditExportResult.failure(cause) : AuditExportResult.failure(); + } + + // Synthesize receipts (IntegrityHash populated by sink in future OTLP extension) + List receipts = new ArrayList<>(records.size()); + for (AuditRecordData record : records) { + receipts.add(AuditReceipt.create(record.getRecordId(), "", 0)); + } + return AuditExportResult.success(receipts); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode shutdown() { + return delegate.shutdown(); + } + + @Override + public String toString() { + StringJoiner joiner = new StringJoiner(", ", "OtlpHttpAuditRecordExporter{", "}"); + joiner.add(builder.toString(false)); + return joiner.toString(); + } +} diff --git a/exporters/otlp/audit/src/main/java/io/opentelemetry/exporter/otlp/http/audit/OtlpHttpAuditRecordExporterBuilder.java b/exporters/otlp/audit/src/main/java/io/opentelemetry/exporter/otlp/http/audit/OtlpHttpAuditRecordExporterBuilder.java new file mode 100644 index 00000000000..6bc63d95740 --- /dev/null +++ b/exporters/otlp/audit/src/main/java/io/opentelemetry/exporter/otlp/http/audit/OtlpHttpAuditRecordExporterBuilder.java @@ -0,0 +1,96 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.exporter.otlp.http.audit; + +import static io.opentelemetry.api.internal.Utils.checkArgument; +import static java.util.Objects.requireNonNull; + +import io.opentelemetry.exporter.otlp.internal.HttpExporterBuilder; +import io.opentelemetry.sdk.common.export.RetryPolicy; +import io.opentelemetry.sdk.common.internal.StandardComponentId; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import javax.annotation.Nullable; +import javax.net.ssl.SSLContext; +import javax.net.ssl.X509TrustManager; + +/** + * Builder for {@link OtlpHttpAuditRecordExporter}. + * + *

Defaults: endpoint {@code http://localhost:4318/v1/audit}, 10 s timeout. + */ +public final class OtlpHttpAuditRecordExporterBuilder { + + private final HttpExporterBuilder delegate; + + OtlpHttpAuditRecordExporterBuilder() { + this.delegate = + new HttpExporterBuilder( + StandardComponentId.ExporterType.OTLP_HTTP_LOG_EXPORTER, + OtlpHttpAuditRecordExporter.DEFAULT_ENDPOINT); + } + + OtlpHttpAuditRecordExporterBuilder(HttpExporterBuilder delegate) { + this.delegate = delegate; + } + + /** Sets the maximum time to wait for the collector to process an exported batch. */ + public OtlpHttpAuditRecordExporterBuilder setTimeout(long timeout, TimeUnit unit) { + requireNonNull(unit, "unit"); + checkArgument(timeout >= 0, "timeout must be non-negative"); + return setTimeout(Duration.ofNanos(unit.toNanos(timeout))); + } + + /** Sets the maximum time to wait for the collector to process an exported batch. */ + public OtlpHttpAuditRecordExporterBuilder setTimeout(Duration timeout) { + requireNonNull(timeout, "timeout"); + delegate.setTimeout(timeout); + return this; + } + + /** Sets the OTLP endpoint URL. Defaults to {@code http://localhost:4318/v1/audit}. */ + public OtlpHttpAuditRecordExporterBuilder setEndpoint(String endpoint) { + requireNonNull(endpoint, "endpoint"); + delegate.setEndpoint(endpoint); + return this; + } + + /** Adds a constant HTTP header sent with every request. */ + public OtlpHttpAuditRecordExporterBuilder addHeader(String key, String value) { + delegate.addConstantHeaders(key, value); + return this; + } + + /** Adds constant HTTP headers sent with every request. */ + public OtlpHttpAuditRecordExporterBuilder setHeaders(Map headers) { + headers.forEach(delegate::addConstantHeaders); + return this; + } + + /** Configures TLS for the OTLP connection. */ + public OtlpHttpAuditRecordExporterBuilder setSslContext( + SSLContext sslContext, X509TrustManager trustManager) { + requireNonNull(sslContext, "sslContext"); + requireNonNull(trustManager, "trustManager"); + delegate.setSslContext(sslContext, trustManager); + return this; + } + + /** + * Sets the retry policy. Audit records MUST NOT be silently dropped on retry exhaustion; the + * exporter will surface a hard error instead. + */ + public OtlpHttpAuditRecordExporterBuilder setRetryPolicy(@Nullable RetryPolicy retryPolicy) { + delegate.setRetryPolicy(retryPolicy); + return this; + } + + /** Builds and returns the configured {@link OtlpHttpAuditRecordExporter}. */ + public OtlpHttpAuditRecordExporter build() { + return new OtlpHttpAuditRecordExporter(delegate, delegate.build()); + } +} diff --git a/exporters/otlp/audit/src/main/java/io/opentelemetry/exporter/otlp/http/audit/package-info.java b/exporters/otlp/audit/src/main/java/io/opentelemetry/exporter/otlp/http/audit/package-info.java new file mode 100644 index 00000000000..003db11e912 --- /dev/null +++ b/exporters/otlp/audit/src/main/java/io/opentelemetry/exporter/otlp/http/audit/package-info.java @@ -0,0 +1,10 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** OTLP Audit Logging exporter. */ +@ParametersAreNonnullByDefault +package io.opentelemetry.exporter.otlp.http.audit; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/exporters/otlp/audit/src/test/java/io/opentelemetry/exporter/otlp/http/audit/AuditLogRecordDataAdapterTest.java b/exporters/otlp/audit/src/test/java/io/opentelemetry/exporter/otlp/http/audit/AuditLogRecordDataAdapterTest.java new file mode 100644 index 00000000000..dc481012903 --- /dev/null +++ b/exporters/otlp/audit/src/test/java/io/opentelemetry/exporter/otlp/http/audit/AuditLogRecordDataAdapterTest.java @@ -0,0 +1,279 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.exporter.otlp.http.audit; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.opentelemetry.api.audit.ActorType; +import io.opentelemetry.api.audit.Outcome; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.sdk.audit.AuditRecordData; +import io.opentelemetry.sdk.audit.SdkAuditRecordData; +import io.opentelemetry.sdk.logs.data.LogRecordData; +import io.opentelemetry.sdk.resources.Resource; +import java.util.Base64; +import org.junit.jupiter.api.Test; + +class AuditLogRecordDataAdapterTest { + + private static final long TIMESTAMP_NANOS = 1_714_041_600_000_000_000L; + private static final long OBSERVED_NANOS = 1_714_041_600_001_000_000L; + + @Test + void mandatoryAttributesMapped() { + AuditRecordData data = buildMinimal(); + LogRecordData adapted = new AuditLogRecordDataAdapter(data); + Attributes attrs = adapted.getAttributes(); + + assertThat(attrs.get(AttributeKey.stringKey("audit.record.id"))).isEqualTo("test-record-id"); + assertThat(attrs.get(AttributeKey.stringKey("audit.actor.id"))).isEqualTo("u8472"); + assertThat(attrs.get(AttributeKey.stringKey("audit.actor.type"))).isEqualTo("user"); + assertThat(attrs.get(AttributeKey.stringKey("audit.action"))).isEqualTo("LOGIN"); + assertThat(attrs.get(AttributeKey.stringKey("audit.outcome"))).isEqualTo("success"); + } + + @Test + void actorType_isLowercase() { + AuditRecordData data = + SdkAuditRecordData.create( + Resource.getDefault(), + TIMESTAMP_NANOS, + OBSERVED_NANOS, + Attributes.empty(), + "test.event", + "test", + null, + null, + "id1", + "svc-1", + ActorType.SERVICE, + "CREATE", + Outcome.SUCCESS, + null, + null, + null, + null, + null, + 0, + null, + null); + LogRecordData adapted = new AuditLogRecordDataAdapter(data); + assertThat(adapted.getAttributes().get(AttributeKey.stringKey("audit.actor.type"))) + .isEqualTo("service"); + } + + @Test + void outcome_isLowercase() { + AuditRecordData data = + SdkAuditRecordData.create( + Resource.getDefault(), + TIMESTAMP_NANOS, + OBSERVED_NANOS, + Attributes.empty(), + "test.event", + "test", + null, + null, + "id2", + "sys", + ActorType.SYSTEM, + "REBOOT", + Outcome.FAILURE, + null, + null, + null, + null, + null, + 0, + null, + null); + LogRecordData adapted = new AuditLogRecordDataAdapter(data); + assertThat(adapted.getAttributes().get(AttributeKey.stringKey("audit.outcome"))) + .isEqualTo("failure"); + } + + @Test + void optionalAttributes_mappedWhenPresent() { + AuditRecordData data = + SdkAuditRecordData.create( + Resource.getDefault(), + TIMESTAMP_NANOS, + OBSERVED_NANOS, + Attributes.empty(), + "resource.access", + "test", + null, + null, + "id3", + "u1", + ActorType.USER, + "READ", + Outcome.SUCCESS, + "/api/data/123", + "http.endpoint", + "10.0.0.1", + "ipv4", + null, + 42L, + "prevhash123", + "1.0.0"); + LogRecordData adapted = new AuditLogRecordDataAdapter(data); + Attributes attrs = adapted.getAttributes(); + + assertThat(attrs.get(AttributeKey.stringKey("audit.target.id"))).isEqualTo("/api/data/123"); + assertThat(attrs.get(AttributeKey.stringKey("audit.target.type"))).isEqualTo("http.endpoint"); + assertThat(attrs.get(AttributeKey.stringKey("audit.source.id"))).isEqualTo("10.0.0.1"); + assertThat(attrs.get(AttributeKey.stringKey("audit.source.type"))).isEqualTo("ipv4"); + assertThat(attrs.get(AttributeKey.longKey("audit.sequence.number"))).isEqualTo(42L); + assertThat(attrs.get(AttributeKey.stringKey("audit.prev.hash"))).isEqualTo("prevhash123"); + assertThat(attrs.get(AttributeKey.stringKey("audit.schema.version"))).isEqualTo("1.0.0"); + } + + @Test + void integrityValue_base64Encoded() { + byte[] proof = new byte[] {0x01, 0x02, 0x03}; + AuditRecordData data = + SdkAuditRecordData.create( + Resource.getDefault(), + TIMESTAMP_NANOS, + OBSERVED_NANOS, + Attributes.empty(), + "signed.event", + "test", + null, + null, + "id4", + "svc", + ActorType.SERVICE, + "SIGN", + Outcome.SUCCESS, + null, + null, + null, + null, + proof, + 0, + null, + null); + LogRecordData adapted = new AuditLogRecordDataAdapter(data); + String encoded = adapted.getAttributes().get(AttributeKey.stringKey("audit.integrity.value")); + assertThat(encoded).isEqualTo(Base64.getEncoder().encodeToString(proof)); + } + + @Test + void instrumentationScope_isEmpty() { + LogRecordData adapted = new AuditLogRecordDataAdapter(buildMinimal()); + assertThat(adapted.getInstrumentationScopeInfo().getName()).isEmpty(); + } + + @Test + void severityNumber_isUndefined() { + LogRecordData adapted = new AuditLogRecordDataAdapter(buildMinimal()); + assertThat(adapted.getSeverity()).isEqualTo(Severity.UNDEFINED_SEVERITY_NUMBER); + assertThat(adapted.getSeverityText()).isNull(); + } + + @Test + void timestampsPreserved() { + LogRecordData adapted = new AuditLogRecordDataAdapter(buildMinimal()); + assertThat(adapted.getTimestampEpochNanos()).isEqualTo(TIMESTAMP_NANOS); + assertThat(adapted.getObservedTimestampEpochNanos()).isEqualTo(OBSERVED_NANOS); + } + + @Test + void eventNamePreserved() { + LogRecordData adapted = new AuditLogRecordDataAdapter(buildMinimal()); + assertThat(adapted.getEventName()).isEqualTo("user.login.success"); + } + + @Test + void resourcePreserved() { + Resource resource = Resource.builder().put("service.name", "auth-svc").build(); + AuditRecordData data = + SdkAuditRecordData.create( + resource, + TIMESTAMP_NANOS, + OBSERVED_NANOS, + Attributes.empty(), + "user.login.success", + "test", + null, + null, + "id5", + "u1", + ActorType.USER, + "LOGIN", + Outcome.SUCCESS, + null, + null, + null, + null, + null, + 0, + null, + null); + LogRecordData adapted = new AuditLogRecordDataAdapter(data); + assertThat(adapted.getResource()).isEqualTo(resource); + } + + @Test + void userAttributes_mergedIntoAdaptedAttributes() { + Attributes userAttrs = Attributes.of(AttributeKey.stringKey("custom.key"), "custom-value"); + AuditRecordData data = + SdkAuditRecordData.create( + Resource.getDefault(), + TIMESTAMP_NANOS, + OBSERVED_NANOS, + userAttrs, + "custom.event", + "test", + null, + null, + "id6", + "u1", + ActorType.USER, + "READ", + Outcome.SUCCESS, + null, + null, + null, + null, + null, + 0, + null, + null); + LogRecordData adapted = new AuditLogRecordDataAdapter(data); + assertThat(adapted.getAttributes().get(AttributeKey.stringKey("custom.key"))) + .isEqualTo("custom-value"); + } + + private static AuditRecordData buildMinimal() { + return SdkAuditRecordData.create( + Resource.getDefault(), + TIMESTAMP_NANOS, + OBSERVED_NANOS, + Attributes.empty(), + "user.login.success", + "test-logger", + null, + null, + "test-record-id", + "u8472", + ActorType.USER, + "LOGIN", + Outcome.SUCCESS, + null, + null, + null, + null, + null, + 0, + null, + null); + } +} diff --git a/exporters/otlp/build.gradle.kts b/exporters/otlp/build.gradle.kts index 7769556c39f..ce4d16f34a5 100644 --- a/exporters/otlp/build.gradle.kts +++ b/exporters/otlp/build.gradle.kts @@ -1,6 +1,6 @@ subprojects { // Workaround https://github.com/gradle/gradle/issues/847 - group = "io.opentelemetry.exporter.otlp" + group = "eu.apeirora.opentelemetry.exporter.otlp" val proj = this plugins.withId("java") { configure { diff --git a/exporters/sender/build.gradle.kts b/exporters/sender/build.gradle.kts index a212101567e..cd8e4a7460d 100644 --- a/exporters/sender/build.gradle.kts +++ b/exporters/sender/build.gradle.kts @@ -1,6 +1,6 @@ subprojects { // Workaround https://github.com/gradle/gradle/issues/847 - group = "io.opentelemetry.exporter.httpsender" + group = "eu.apeirora.opentelemetry.exporter.httpsender" val proj = this plugins.withId("java") { configure { diff --git a/extensions/build.gradle.kts b/extensions/build.gradle.kts index f0744dfe9ef..0ad8e49d0c7 100644 --- a/extensions/build.gradle.kts +++ b/extensions/build.gradle.kts @@ -1,6 +1,6 @@ subprojects { // Workaround https://github.com/gradle/gradle/issues/847 - group = "io.opentelemetry.extensions" + group = "eu.apeirora.opentelemetry.extensions" val proj = this plugins.withId("java") { configure { diff --git a/gradle.properties b/gradle.properties index 0d9cc9c5012..326a5922813 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ -org.gradle.parallel=true -org.gradle.caching=true -org.gradle.configuration-cache=true -org.gradle.configuration-cache.parallel=true +org.gradle.parallel=false +org.gradle.caching=false +org.gradle.configuration-cache=false +org.gradle.configuration-cache.parallel=false org.gradle.priority=low diff --git a/sdk-extensions/autoconfigure-spi/src/main/java/io/opentelemetry/sdk/autoconfigure/spi/audit/ConfigurableAuditRecordExporterProvider.java b/sdk-extensions/autoconfigure-spi/src/main/java/io/opentelemetry/sdk/autoconfigure/spi/audit/ConfigurableAuditRecordExporterProvider.java new file mode 100644 index 00000000000..d714900f6c2 --- /dev/null +++ b/sdk-extensions/autoconfigure-spi/src/main/java/io/opentelemetry/sdk/autoconfigure/spi/audit/ConfigurableAuditRecordExporterProvider.java @@ -0,0 +1,32 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.autoconfigure.spi.audit; + +import io.opentelemetry.sdk.audit.AuditRecordExporter; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; + +/** + * A service provider interface (SPI) for providing audit record exporters that can be used with the + * autoconfigured SDK. If the {@code otel.audit.exporter} property contains a value equal to what is + * returned by {@link #getName()}, the exporter returned by {@link + * #createExporter(ConfigProperties)} will be enabled and added to the audit pipeline. + * + *

This SPI is at {@code Development} stability; the interface may change in future releases. + */ +public interface ConfigurableAuditRecordExporterProvider { + + /** + * Returns an {@link AuditRecordExporter} that can be registered to the audit pipeline by + * providing the property value specified by {@link #getName()}. + */ + AuditRecordExporter createExporter(ConfigProperties config); + + /** + * Returns the name of this exporter, which can be specified with the {@code otel.audit.exporter} + * property to enable it. The name returned MUST NOT be the same as any other exporter name. + */ + String getName(); +} diff --git a/sdk-extensions/autoconfigure-spi/src/main/java/io/opentelemetry/sdk/autoconfigure/spi/audit/package-info.java b/sdk-extensions/autoconfigure-spi/src/main/java/io/opentelemetry/sdk/autoconfigure/spi/audit/package-info.java new file mode 100644 index 00000000000..78ed13d9040 --- /dev/null +++ b/sdk-extensions/autoconfigure-spi/src/main/java/io/opentelemetry/sdk/autoconfigure/spi/audit/package-info.java @@ -0,0 +1,16 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Java SPI (Service Provider Interface) for implementing extensions to SDK autoconfiguration of + * audit logging. + * + *

This package is at {@code Development} stability; the interfaces may change in future + * releases. + */ +@ParametersAreNonnullByDefault +package io.opentelemetry.sdk.autoconfigure.spi.audit; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/sdk-extensions/autoconfigure/src/main/java/io/opentelemetry/sdk/autoconfigure/AuditExporterConfiguration.java b/sdk-extensions/autoconfigure/src/main/java/io/opentelemetry/sdk/autoconfigure/AuditExporterConfiguration.java new file mode 100644 index 00000000000..0c73d19f0c8 --- /dev/null +++ b/sdk-extensions/autoconfigure/src/main/java/io/opentelemetry/sdk/autoconfigure/AuditExporterConfiguration.java @@ -0,0 +1,66 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.autoconfigure; + +import io.opentelemetry.sdk.audit.AuditRecordExporter; +import io.opentelemetry.sdk.autoconfigure.internal.NamedSpiManager; +import io.opentelemetry.sdk.autoconfigure.internal.SpiHelper; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigurationException; +import io.opentelemetry.sdk.autoconfigure.spi.audit.ConfigurableAuditRecordExporterProvider; +import io.opentelemetry.sdk.autoconfigure.spi.internal.DefaultConfigProperties; +import java.io.Closeable; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +final class AuditExporterConfiguration { + + private static final String EXPORTER_NONE = "none"; + + static Map configureAuditRecordExporters( + ConfigProperties config, SpiHelper spiHelper, List closeables) { + Set exporterNames = DefaultConfigProperties.getSet(config, "otel.audit.exporter"); + + if (exporterNames.contains(EXPORTER_NONE)) { + if (exporterNames.size() > 1) { + throw new ConfigurationException( + "otel.audit.exporter contains " + EXPORTER_NONE + " along with other exporters"); + } + return Collections.emptyMap(); + } + + if (exporterNames.isEmpty()) { + exporterNames = Collections.singleton("otlp"); + } + + NamedSpiManager spiManager = auditExporterSpiManager(config, spiHelper); + + Map map = new HashMap<>(); + for (String name : exporterNames) { + AuditRecordExporter exporter = spiManager.getByName(name); + if (exporter == null) { + throw new ConfigurationException("Unrecognized value for otel.audit.exporter: " + name); + } + closeables.add(exporter); + map.put(name, exporter); + } + return Collections.unmodifiableMap(map); + } + + static NamedSpiManager auditExporterSpiManager( + ConfigProperties config, SpiHelper spiHelper) { + return spiHelper.loadConfigurable( + ConfigurableAuditRecordExporterProvider.class, + ConfigurableAuditRecordExporterProvider::getName, + ConfigurableAuditRecordExporterProvider::createExporter, + config); + } + + private AuditExporterConfiguration() {} +} diff --git a/sdk-extensions/autoconfigure/src/main/java/io/opentelemetry/sdk/autoconfigure/AuditProviderConfiguration.java b/sdk-extensions/autoconfigure/src/main/java/io/opentelemetry/sdk/autoconfigure/AuditProviderConfiguration.java new file mode 100644 index 00000000000..1a4bda0df96 --- /dev/null +++ b/sdk-extensions/autoconfigure/src/main/java/io/opentelemetry/sdk/autoconfigure/AuditProviderConfiguration.java @@ -0,0 +1,52 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.autoconfigure; + +import io.opentelemetry.api.audit.GlobalAuditProvider; +import io.opentelemetry.sdk.audit.AuditRecordExporter; +import io.opentelemetry.sdk.audit.AuditRecordProcessor; +import io.opentelemetry.sdk.audit.SdkAuditProvider; +import io.opentelemetry.sdk.audit.SdkAuditProviderBuilder; +import io.opentelemetry.sdk.audit.export.SimpleAuditRecordProcessor; +import io.opentelemetry.sdk.autoconfigure.internal.SpiHelper; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.resources.Resource; +import java.io.Closeable; +import java.util.List; +import java.util.Map; + +/** + * Configures an {@link SdkAuditProvider} from autoconfiguration properties and registers it as the + * global {@link io.opentelemetry.api.audit.AuditProvider}. + * + *

The property {@code otel.audit.exporter} controls which exporter is used (default: {@code + * otlp}). Use {@code otel.audit.exporter=none} to disable audit logging. + */ +final class AuditProviderConfiguration { + + static SdkAuditProvider configureAuditProvider( + Resource resource, ConfigProperties config, SpiHelper spiHelper, List closeables) { + + Map exportersByName = + AuditExporterConfiguration.configureAuditRecordExporters(config, spiHelper, closeables); + + SdkAuditProviderBuilder builder = SdkAuditProvider.builder().setResource(resource); + + // Each configured exporter gets its own SimpleAuditRecordProcessor in the chain. + for (AuditRecordExporter exporter : exportersByName.values()) { + AuditRecordProcessor processor = SimpleAuditRecordProcessor.create(exporter); + closeables.add(processor); + builder.addAuditRecordProcessor(processor); + } + + SdkAuditProvider provider = builder.build(); + closeables.add(provider); + GlobalAuditProvider.set(provider); + return provider; + } + + private AuditProviderConfiguration() {} +} diff --git a/sdk-extensions/build.gradle.kts b/sdk-extensions/build.gradle.kts index bcb76a95ae0..9a5be668b6a 100644 --- a/sdk-extensions/build.gradle.kts +++ b/sdk-extensions/build.gradle.kts @@ -1,6 +1,6 @@ subprojects { // Workaround https://github.com/gradle/gradle/issues/847 - group = "io.opentelemetry.sdk.extensions" + group = "eu.apeirora.opentelemetry.sdk.extensions" val proj = this plugins.withId("java") { configure { diff --git a/sdk/all/build.gradle.kts b/sdk/all/build.gradle.kts index 313726e1723..ca0b1dd4a1a 100644 --- a/sdk/all/build.gradle.kts +++ b/sdk/all/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { api(project(":sdk:trace")) api(project(":sdk:metrics")) api(project(":sdk:logs")) + api(project(":sdk:audit")) compileOnly(project(":api:incubator")) diff --git a/sdk/audit/build.gradle.kts b/sdk/audit/build.gradle.kts new file mode 100644 index 00000000000..a8c4f110d81 --- /dev/null +++ b/sdk/audit/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + id("otel.java-conventions") + id("otel.publish-conventions") + id("otel.animalsniffer-conventions") +} + +description = "OpenTelemetry Audit Logging SDK" +otelJava.moduleName.set("io.opentelemetry.sdk.audit") +otelJava.osgiUnversionedOptionalPackages.add("audit.log") + +dependencies { + api(project(":api:all")) + api(project(":sdk:common")) + api(project(":sdk:logs")) + api("eu.apeirora:audit.log") + + annotationProcessor("com.google.auto.value:auto-value") + + testImplementation(project(":sdk:testing")) + + testImplementation("org.awaitility:awaitility") + testImplementation("com.google.guava:guava") +} diff --git a/sdk/audit/gradle.properties b/sdk/audit/gradle.properties new file mode 100644 index 00000000000..4476ae57e31 --- /dev/null +++ b/sdk/audit/gradle.properties @@ -0,0 +1 @@ +otel.release=alpha diff --git a/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/AuditExportResult.java b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/AuditExportResult.java new file mode 100644 index 00000000000..729c939d06d --- /dev/null +++ b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/AuditExportResult.java @@ -0,0 +1,69 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.audit; + +import io.opentelemetry.api.audit.AuditReceipt; +import java.util.Collections; +import java.util.List; +import javax.annotation.Nullable; + +/** + * The synchronous result of an {@link AuditRecordExporter#export} call. + * + *

On success, {@link #getReceipts()} contains one {@link AuditReceipt} per exported record in + * the same order as the input collection. On failure, the list is empty and {@link #isSuccess()} + * returns {@code false}. + */ +public class AuditExportResult { + + private final boolean success; + private final List receipts; + @Nullable private final Throwable failure; + + private AuditExportResult( + boolean success, List receipts, @Nullable Throwable failure) { + this.success = success; + this.receipts = receipts; + this.failure = failure; + } + + /** Creates a successful result with the given receipts. */ + public static AuditExportResult success(List receipts) { + return new AuditExportResult(/* success= */ true, Collections.unmodifiableList(receipts), null); + } + + /** Creates a failure result with the given cause. */ + public static AuditExportResult failure(Throwable cause) { + return new AuditExportResult(/* success= */ false, Collections.emptyList(), cause); + } + + /** Creates a failure result without a specific cause. */ + public static AuditExportResult failure() { + return new AuditExportResult(/* success= */ false, Collections.emptyList(), null); + } + + /** Returns {@code true} if all records were successfully acknowledged by the audit sink. */ + public boolean isSuccess() { + return success; + } + + /** + * Returns the {@link AuditReceipt}s returned by the audit sink, one per exported record. Empty if + * {@link #isSuccess()} is {@code false}. + */ + public List getReceipts() { + return receipts; + } + + /** + * Returns the cause of the failure, or {@code null} if the failure has no associated throwable or + * if the export succeeded. + */ + @Nullable + public Throwable getFailure() { + return failure; + } +} diff --git a/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/AuditRecordData.java b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/AuditRecordData.java new file mode 100644 index 00000000000..44255ef448d --- /dev/null +++ b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/AuditRecordData.java @@ -0,0 +1,131 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.audit; + +import io.opentelemetry.api.audit.ActorType; +import io.opentelemetry.api.audit.Outcome; +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.sdk.common.InstrumentationScopeInfo; +import io.opentelemetry.sdk.logs.data.Body; +import io.opentelemetry.sdk.logs.data.LogRecordData; +import javax.annotation.Nullable; + +/** + * Immutable representation of an audit record for use by processors and exporters. + * + *

Instances are created internally by the SDK when {@link + * io.opentelemetry.api.audit.AuditRecordBuilder#emit()} is called. + */ +public interface AuditRecordData extends LogRecordData { + + // Audit records do not use LogRecordData's instrumentation scope, span context, severity, or body + // fields. These defaults prevent AutoValue from generating constructor parameters for them. + + @Override + default InstrumentationScopeInfo getInstrumentationScopeInfo() { + return InstrumentationScopeInfo.empty(); + } + + @Override + default SpanContext getSpanContext() { + return SpanContext.getInvalid(); + } + + @Override + default Severity getSeverity() { + return Severity.UNDEFINED_SEVERITY_NUMBER; + } + + @Override + @Nullable + default String getSeverityText() { + return null; + } + + @Override + @Deprecated + default Body getBody() { + return Body.empty(); + } + + @Override + default int getTotalAttributeCount() { + return getAttributes().size(); + } + + /** Returns the semantic event name for this audit record. Never null or empty. */ + @Override + String getEventName(); + + /** + * Returns the diagnostic name of the {@link io.opentelemetry.api.audit.AuditLogger} that emitted + * this record (for example {@code "com.example.auth"}). + */ + String getLoggerName(); + + /** Returns the optional version of the emitting component, or {@code null} if not set. */ + @Nullable + String getLoggerVersion(); + + /** Returns the optional schema URL, or {@code null} if not set. */ + @Nullable + String getSchemaUrl(); + + /** Returns the caller-generated unique identifier for this record. Never null or empty. */ + String getRecordId(); + + /** Returns the identity of the actor ({@code audit.actor.id}). */ + String getActorId(); + + /** Returns the type of the actor ({@code audit.actor.type}). */ + ActorType getActorType(); + + /** Returns the action verb ({@code audit.action}), e.g. {@code "LOGIN"}, {@code "DELETE"}. */ + String getAction(); + + /** Returns the outcome of the action ({@code audit.outcome}). */ + Outcome getOutcome(); + + /** Returns the {@code audit.target.id}, or {@code null} if not set. */ + @Nullable + String getTargetId(); + + /** Returns the {@code audit.target.type}, or {@code null} if not set. */ + @Nullable + String getTargetType(); + + /** Returns the {@code audit.source.id}, or {@code null} if not set. */ + @Nullable + String getSourceId(); + + /** Returns the {@code audit.source.type}, or {@code null} if not set. */ + @Nullable + String getSourceType(); + + /** + * Returns the raw bytes of the cryptographic integrity proof ({@code audit.integrity.value}), or + * {@code null} if not set. The algorithm is carried as the {@code audit.integrity.algorithm} + * Resource attribute. + */ + @SuppressWarnings("mutable") + @Nullable + byte[] getIntegrityValue(); + + /** Returns the monotonic sequence number for hash-chain continuity, or {@code 0} if not set. */ + long getSequenceNo(); + + /** + * Returns the {@code audit.prev.hash} of the preceding record for hash-chain linking, or {@code + * null} if not set. + */ + @Nullable + String getPrevHash(); + + /** Returns the {@code audit.schema.version}, or {@code null} if not set. */ + @Nullable + String getSchemaVersion(); +} diff --git a/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/AuditRecordExporter.java b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/AuditRecordExporter.java new file mode 100644 index 00000000000..6155dde0210 --- /dev/null +++ b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/AuditRecordExporter.java @@ -0,0 +1,56 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.audit; + +import io.opentelemetry.sdk.common.CompletableResultCode; +import java.io.Closeable; +import java.util.Collection; +import java.util.concurrent.TimeUnit; + +/** + * Transmits {@link AuditRecordData}s to the configured audit sink. + * + *

Implementations MUST document their concurrency characteristics. {@link #export} MUST NOT be + * called concurrently on the same instance. + * + *

The audit logging specification prohibits partial success: if the receiver cannot process one + * or more records, the entire batch MUST be rejected. Implementations MUST treat a partial-success + * response from the OTLP receiver as a hard failure and retry the full batch. + */ +public interface AuditRecordExporter extends Closeable { + + /** + * Exports the given collection of {@link AuditRecordData}s to the audit sink. + * + *

MUST NOT be called concurrently on the same exporter instance. MUST NOT block indefinitely; + * the exporter MUST time out within the configured export timeout. + * + * @param records the records to export; the collection MUST NOT be mutated after this call + * @return an {@link AuditExportResult} containing one {@link + * io.opentelemetry.api.audit.AuditReceipt} per record on success, or a failure result + */ + AuditExportResult export(Collection records); + + /** + * Requests that any internally buffered records be exported immediately. + * + * @return a result indicating whether the flush succeeded + */ + CompletableResultCode flush(); + + /** + * Shuts down this exporter. On the first call, flushes any buffered records and releases all + * resources. Subsequent calls are no-ops. + * + * @return a result indicating whether the shutdown succeeded + */ + CompletableResultCode shutdown(); + + @Override + default void close() { + shutdown().join(10, TimeUnit.SECONDS); + } +} diff --git a/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/AuditRecordProcessor.java b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/AuditRecordProcessor.java new file mode 100644 index 00000000000..5b93769484c --- /dev/null +++ b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/AuditRecordProcessor.java @@ -0,0 +1,76 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.audit; + +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.common.CompletableResultCode; +import java.io.Closeable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; +import javax.annotation.concurrent.ThreadSafe; + +/** + * Interface for hooking into the audit record pipeline for enrichment and forwarding. + * + *

Processors MUST only add attributes to records (enrichment). They MUST NOT remove mandatory + * fields, filter records, aggregate records, or introduce sampling. Processors that would remove or + * filter records are rejected at configuration time by {@link SdkAuditProvider}. + */ +@ThreadSafe +public interface AuditRecordProcessor extends Closeable { + + /** + * Returns a composite {@link AuditRecordProcessor} that delegates to all given processors in + * order. + */ + static AuditRecordProcessor composite(AuditRecordProcessor... processors) { + return composite(Arrays.asList(processors)); + } + + /** + * Returns a composite {@link AuditRecordProcessor} that delegates to all given processors in + * order. + */ + static AuditRecordProcessor composite(Iterable processors) { + List list = new ArrayList<>(); + for (AuditRecordProcessor p : processors) { + list.add(p); + } + if (list.size() == 1) { + return list.get(0); + } + return MultiAuditRecordProcessor.create(list); + } + + /** + * Called synchronously on the calling thread after the record has been enqueued and before the + * {@link io.opentelemetry.api.audit.AuditReceipt} is returned to the caller. + * + *

Implementations MAY enrich {@code record} by adding attributes. They MUST NOT block + * indefinitely. + * + * @param context the ambient {@link Context} at the time of {@code emit()} + * @param record the mutable record; enrichment only + */ + void onEmit(Context context, ReadWriteAuditRecord record); + + /** Shuts down this processor, flushing all buffered records. */ + default CompletableResultCode shutdown() { + return forceFlush(); + } + + /** Requests that all buffered records be exported as soon as possible. */ + default CompletableResultCode forceFlush() { + return CompletableResultCode.ofSuccess(); + } + + @Override + default void close() { + shutdown().join(10, TimeUnit.SECONDS); + } +} diff --git a/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/MultiAuditRecordProcessor.java b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/MultiAuditRecordProcessor.java new file mode 100644 index 00000000000..3fcc7c04046 --- /dev/null +++ b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/MultiAuditRecordProcessor.java @@ -0,0 +1,50 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.audit; + +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.common.CompletableResultCode; +import java.util.ArrayList; +import java.util.Collection; + +/** Composite {@link AuditRecordProcessor} that delegates to multiple processors in order. */ +public class MultiAuditRecordProcessor implements AuditRecordProcessor { + + private final Collection processors; + + private MultiAuditRecordProcessor(Collection processors) { + this.processors = processors; + } + + static MultiAuditRecordProcessor create(Collection processors) { + return new MultiAuditRecordProcessor(new ArrayList<>(processors)); + } + + @Override + public void onEmit(Context context, ReadWriteAuditRecord record) { + for (AuditRecordProcessor processor : processors) { + processor.onEmit(context, record); + } + } + + @Override + public CompletableResultCode shutdown() { + Collection results = new ArrayList<>(processors.size()); + for (AuditRecordProcessor processor : processors) { + results.add(processor.shutdown()); + } + return CompletableResultCode.ofAll(results); + } + + @Override + public CompletableResultCode forceFlush() { + Collection results = new ArrayList<>(processors.size()); + for (AuditRecordProcessor processor : processors) { + results.add(processor.forceFlush()); + } + return CompletableResultCode.ofAll(results); + } +} diff --git a/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/ReadWriteAuditRecord.java b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/ReadWriteAuditRecord.java new file mode 100644 index 00000000000..b438a49d18a --- /dev/null +++ b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/ReadWriteAuditRecord.java @@ -0,0 +1,62 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.audit; + +import io.opentelemetry.api.audit.ActorType; +import io.opentelemetry.api.audit.AuditReceipt; +import io.opentelemetry.api.audit.Outcome; +import io.opentelemetry.api.common.AttributeKey; +import javax.annotation.Nullable; + +/** + * Mutable view of an {@link AuditRecordData} passed to {@link AuditRecordProcessor#onEmit}. + * + *

Processors MAY enrich the record by calling {@link #setAttribute}. They MUST NOT modify the + * mandatory audit fields ({@code EventName}, {@code ActorId}, {@code ActorType}, {@code Action}, + * {@code Outcome}): those are exposed as read-only accessors. + * + *

The {@link #setReceipt}/{@link #getReceipt} pair is used internally by the SDK to thread the + * {@link AuditReceipt} returned by the exporter back to the calling {@code emit()} invocation. + */ +public interface ReadWriteAuditRecord { + + /** + * Adds or replaces an attribute on this record. A {@code null} value is a no-op. + * + *

MUST NOT be called to modify mandatory fields; only additional enrichment attributes are + * permitted. + */ + ReadWriteAuditRecord setAttribute(AttributeKey key, @Nullable T value); + + /** + * Stores the {@link AuditReceipt} returned by the exporter. Called by the SDK after a successful + * export. + */ + void setReceipt(AuditReceipt receipt); + + /** Returns the {@link AuditReceipt} set by the exporter, or {@code null} if not yet set. */ + @Nullable + AuditReceipt getReceipt(); + + /** Snapshots this record into an immutable {@link AuditRecordData} for export. */ + AuditRecordData toAuditRecordData(); + + // ── Read-only accessors for mandatory fields ────────────────────────────── + + String getRecordId(); + + long getTimestampEpochNanos(); + + String getEventName(); + + String getActorId(); + + ActorType getActorType(); + + String getAction(); + + Outcome getOutcome(); +} diff --git a/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/SdkAuditLogger.java b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/SdkAuditLogger.java new file mode 100644 index 00000000000..052ff28af78 --- /dev/null +++ b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/SdkAuditLogger.java @@ -0,0 +1,28 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.audit; + +import io.opentelemetry.api.audit.AuditLogger; +import io.opentelemetry.api.audit.AuditRecordBuilder; +import javax.annotation.concurrent.ThreadSafe; + +/** SDK implementation of {@link AuditLogger}. */ +@ThreadSafe +public class SdkAuditLogger implements AuditLogger { + + private final SdkAuditProvider provider; + private final SdkAuditProvider.AuditLoggerKey key; + + SdkAuditLogger(SdkAuditProvider provider, SdkAuditProvider.AuditLoggerKey key) { + this.provider = provider; + this.key = key; + } + + @Override + public AuditRecordBuilder auditRecordBuilder() { + return new SdkAuditRecordBuilder(provider, key); + } +} diff --git a/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/SdkAuditLoggerBuilder.java b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/SdkAuditLoggerBuilder.java new file mode 100644 index 00000000000..4441a5361ea --- /dev/null +++ b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/SdkAuditLoggerBuilder.java @@ -0,0 +1,42 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.audit; + +import io.opentelemetry.api.audit.AuditLogger; +import io.opentelemetry.api.audit.AuditLoggerBuilder; +import javax.annotation.Nullable; + +/** SDK implementation of {@link AuditLoggerBuilder}. */ +public class SdkAuditLoggerBuilder implements AuditLoggerBuilder { + + private final SdkAuditProvider provider; + private final String name; + @Nullable private String version; + @Nullable private String schemaUrl; + + SdkAuditLoggerBuilder(SdkAuditProvider provider, String name) { + this.provider = provider; + this.name = name; + } + + @Override + public AuditLoggerBuilder setSchemaUrl(String schemaUrl) { + this.schemaUrl = schemaUrl; + return this; + } + + @Override + public AuditLoggerBuilder setInstrumentationVersion(String instrumentationVersion) { + this.version = instrumentationVersion; + return this; + } + + @Override + public AuditLogger build() { + return provider.getOrCreateLogger( + new SdkAuditProvider.AuditLoggerKey(name, version, schemaUrl)); + } +} diff --git a/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/SdkAuditProvider.java b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/SdkAuditProvider.java new file mode 100644 index 00000000000..5bdcf3e3da3 --- /dev/null +++ b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/SdkAuditProvider.java @@ -0,0 +1,182 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.audit; + +import io.opentelemetry.api.audit.AuditDeliveryException; +import io.opentelemetry.api.audit.AuditLoggerBuilder; +import io.opentelemetry.api.audit.AuditProvider; +import io.opentelemetry.sdk.common.Clock; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.resources.Resource; +import java.io.Closeable; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.annotation.Nullable; + +/** + * SDK implementation of {@link AuditProvider}. + * + *

Create via {@link #builder()}. The provider maintains a dedicated queue and exporter pipeline + * that is completely independent of the Log signal pipeline. + * + *

The provider intentionally does NOT expose sampler configuration. Any attempt to configure + * sampling on the audit pipeline is a configuration error and will be rejected. + */ +public class SdkAuditProvider implements AuditProvider, Closeable { + + private static final Logger logger = Logger.getLogger(SdkAuditProvider.class.getName()); + + private final Resource resource; + private final AuditRecordProcessor processor; + private final Clock clock; + private final ConcurrentHashMap loggerRegistry = + new ConcurrentHashMap<>(); + private final AtomicBoolean isShutdown = new AtomicBoolean(false); + + SdkAuditProvider(Resource resource, List processors, Clock clock) { + this.resource = resource; + this.processor = AuditRecordProcessor.composite(processors); + this.clock = clock; + } + + /** Returns a new {@link SdkAuditProviderBuilder}. */ + public static SdkAuditProviderBuilder builder() { + return new SdkAuditProviderBuilder(); + } + + @Override + public AuditLoggerBuilder auditLoggerBuilder(String name) { + if (isShutdown.get()) { + throw new AuditDeliveryException( + "AuditProvider has been shut down; cannot create new AuditLoggers"); + } + if (name.isEmpty()) { + logger.log( + Level.WARNING, + "AuditProvider.auditLoggerBuilder() called with empty name; using 'unknown'"); + return new SdkAuditLoggerBuilder(this, "unknown"); + } + return new SdkAuditLoggerBuilder(this, name); + } + + /** Returns the {@link SdkAuditLogger} for the given key, creating it if necessary. */ + SdkAuditLogger getOrCreateLogger(AuditLoggerKey key) { + return loggerRegistry.computeIfAbsent(key, k -> new SdkAuditLogger(this, k)); + } + + Resource getResource() { + return resource; + } + + AuditRecordProcessor getProcessor() { + return processor; + } + + Clock getClock() { + return clock; + } + + boolean isShutdown() { + return isShutdown.get(); + } + + /** + * Shuts down this provider. Calls {@link #forceFlush()} then shuts down all registered + * processors. + * + * @return a result indicating whether shutdown succeeded + */ + public CompletableResultCode shutdown() { + if (!isShutdown.compareAndSet(false, true)) { + return CompletableResultCode.ofSuccess(); + } + CompletableResultCode result = new CompletableResultCode(); + CompletableResultCode flushResult = forceFlush(); + flushResult.whenComplete( + () -> { + CompletableResultCode shutdownResult = processor.shutdown(); + shutdownResult.whenComplete( + () -> { + if (!flushResult.isSuccess() || !shutdownResult.isSuccess()) { + result.fail(); + } else { + result.succeed(); + } + }); + }); + return result; + } + + /** + * Forces all buffered audit records to be exported. + * + * @return a result indicating whether the flush succeeded + */ + public CompletableResultCode forceFlush() { + if (isShutdown.get()) { + return CompletableResultCode.ofSuccess(); + } + return processor.forceFlush(); + } + + @Override + public void close() { + shutdown().join(10, TimeUnit.SECONDS); + } + + // ── Inner key type ──────────────────────────────────────────────────────── + + static final class AuditLoggerKey { + + private final String name; + @Nullable private final String version; + @Nullable private final String schemaUrl; + + AuditLoggerKey(String name, @Nullable String version, @Nullable String schemaUrl) { + this.name = name; + this.version = version; + this.schemaUrl = schemaUrl; + } + + String getName() { + return name; + } + + @Nullable + String getVersion() { + return version; + } + + @Nullable + String getSchemaUrl() { + return schemaUrl; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof AuditLoggerKey)) { + return false; + } + AuditLoggerKey other = (AuditLoggerKey) obj; + return name.equals(other.name) + && Objects.equals(version, other.version) + && Objects.equals(schemaUrl, other.schemaUrl); + } + + @Override + public int hashCode() { + return Objects.hash(name, version, schemaUrl); + } + } +} diff --git a/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/SdkAuditProviderBuilder.java b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/SdkAuditProviderBuilder.java new file mode 100644 index 00000000000..bcc3e3f9253 --- /dev/null +++ b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/SdkAuditProviderBuilder.java @@ -0,0 +1,99 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.audit; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.sdk.common.Clock; +import io.opentelemetry.sdk.resources.Resource; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nullable; + +/** Builder for {@link SdkAuditProvider}. */ +public class SdkAuditProviderBuilder { + + public static final AttributeKey ATTR_INTEGRITY_ALGORITHM = + AttributeKey.stringKey("audit.integrity.algorithm"); + public static final AttributeKey ATTR_INTEGRITY_CERTIFICATE = + AttributeKey.stringKey("audit.integrity.certificate"); + + private Resource resource = Resource.getDefault(); + private Clock clock = Clock.getDefault(); + private final List processors = new ArrayList<>(); + @Nullable private String integrityAlgorithm; + @Nullable private String integrityCertificate; + + SdkAuditProviderBuilder() {} + + /** Sets the {@link Resource} to be associated with all audit records emitted by this provider. */ + public SdkAuditProviderBuilder setResource(Resource resource) { + this.resource = resource; + return this; + } + + /** Sets the {@link Clock} used for {@code ObservedTimestamp} generation. */ + public SdkAuditProviderBuilder setClock(Clock clock) { + this.clock = clock; + return this; + } + + /** + * Adds an {@link AuditRecordProcessor} to the pipeline. Processors are invoked in the order they + * are added. + * + *

The last processor in the chain is responsible for forwarding records to the exporter and + * setting the {@link io.opentelemetry.api.audit.AuditReceipt} on the record. + */ + public SdkAuditProviderBuilder addAuditRecordProcessor(AuditRecordProcessor processor) { + processors.add(processor); + return this; + } + + /** + * Sets the {@code audit.integrity.algorithm} Resource attribute. MUST be set when any record + * emitted by this provider carries an {@code audit.integrity.value} (i.e. when {@link + * io.opentelemetry.api.audit.AuditRecordBuilder#setIntegrityValue(byte[])} is used). + * + *

For asymmetric signatures use a JWA algorithm identifier (e.g. {@code "ES256"}, {@code + * "RS256"}, {@code "EdDSA"}). For HMACs use an IANA MAC algorithm identifier (e.g. {@code + * "HMAC-SHA256"}). + */ + public SdkAuditProviderBuilder setIntegrityAlgorithm(String algorithm) { + this.integrityAlgorithm = algorithm; + return this; + } + + /** + * Sets the {@code audit.integrity.certificate} Resource attribute. MUST NOT be set for HMAC + * algorithms. + * + *

The value MUST be one of: base64-encoded DER certificate, fingerprint ({@code sha256:} + * or {@code sha1:}), JWK Key ID, Subject Key Identifier (colon-separated hex), or + * Issuer+Serial ({@code CN=...,O=.../serial}). + */ + public SdkAuditProviderBuilder setIntegrityCertificate(String certificate) { + this.integrityCertificate = certificate; + return this; + } + + /** Builds and returns the configured {@link SdkAuditProvider}. */ + public SdkAuditProvider build() { + if (integrityAlgorithm != null || integrityCertificate != null) { + AttributesBuilder integrityAttrs = Attributes.builder(); + if (integrityAlgorithm != null) { + integrityAttrs.put(ATTR_INTEGRITY_ALGORITHM, integrityAlgorithm); + } + if (integrityCertificate != null) { + integrityAttrs.put(ATTR_INTEGRITY_CERTIFICATE, integrityCertificate); + } + return new SdkAuditProvider( + resource.merge(Resource.create(integrityAttrs.build())), processors, clock); + } + return new SdkAuditProvider(resource, processors, clock); + } +} diff --git a/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/SdkAuditRecordBuilder.java b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/SdkAuditRecordBuilder.java new file mode 100644 index 00000000000..19ab4faffb2 --- /dev/null +++ b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/SdkAuditRecordBuilder.java @@ -0,0 +1,355 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.audit; + +import audit.log.Actor; +import audit.log.AuditEvent; +import audit.log.Source; +import audit.log.Target; +import io.opentelemetry.api.audit.ActorType; +import io.opentelemetry.api.audit.Audit; +import io.opentelemetry.api.audit.AuditDeliveryException; +import io.opentelemetry.api.audit.AuditReceipt; +import io.opentelemetry.api.audit.AuditRecordBuilder; +import io.opentelemetry.api.audit.Outcome; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Value; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.common.internal.AttributesMap; +import java.time.Instant; +import java.util.Locale; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import javax.annotation.Nullable; + +/** SDK implementation of {@link AuditRecordBuilder}. */ +class SdkAuditRecordBuilder implements AuditRecordBuilder { + + private final SdkAuditProvider provider; + private final SdkAuditProvider.AuditLoggerKey loggerKey; + + // Required fields + private long timestampEpochNanos; + @Nullable private String eventName; + + // Optional fields + private long observedTimestampEpochNanos; + @Nullable private String schemaVersion; + @Nullable private byte[] integrityValue; + private AttributesMap attributes = AttributesMap.create(128, Integer.MAX_VALUE); + + SdkAuditRecordBuilder(SdkAuditProvider provider, SdkAuditProvider.AuditLoggerKey loggerKey) { + this.provider = provider; + this.loggerKey = loggerKey; + } + + @Override + public SdkAuditRecordBuilder setRecordId(String recordId) { + attributes.put(AttributeKey.stringKey(Audit.RECORD_ID), recordId); + return this; + } + + @Override + public SdkAuditRecordBuilder setTimestamp(long timestamp, TimeUnit unit) { + this.timestampEpochNanos = unit.toNanos(timestamp); + return this; + } + + @Override + public SdkAuditRecordBuilder setTimestamp(Instant instant) { + this.timestampEpochNanos = + TimeUnit.SECONDS.toNanos(instant.getEpochSecond()) + instant.getNano(); + return this; + } + + @Override + public SdkAuditRecordBuilder setEventName(String eventName) { + this.eventName = eventName; + return this; + } + + @Override + public SdkAuditRecordBuilder setActor(String actorId, ActorType actorType) { + attributes.put(AttributeKey.stringKey(Audit.ACTOR_TYPE), actorType.name()); + attributes.put(AttributeKey.stringKey(Audit.ACTOR_ID), actorId); + return this; + } + + @Override + public SdkAuditRecordBuilder setAction(String action) { + attributes.put(AttributeKey.stringKey(Audit.ACTION), action); + return this; + } + + @Override + public SdkAuditRecordBuilder setOutcome(Outcome outcome) { + attributes.put(AttributeKey.stringKey(Audit.OUTCOME), outcome.name()); + return this; + } + + @Override + public SdkAuditRecordBuilder setObservedTimestamp(long timestamp, TimeUnit unit) { + this.observedTimestampEpochNanos = unit.toNanos(timestamp); + return this; + } + + @Override + public SdkAuditRecordBuilder setObservedTimestamp(Instant instant) { + this.observedTimestampEpochNanos = + TimeUnit.SECONDS.toNanos(instant.getEpochSecond()) + instant.getNano(); + return this; + } + + @Override + public SdkAuditRecordBuilder setSchemaVersion(String schemaVersion) { + this.schemaVersion = schemaVersion; + return this; + } + + @Override + public SdkAuditRecordBuilder setTarget(String targetId, String targetType) { + attributes.put(AttributeKey.stringKey(Audit.TARGET_TYPE), targetType); + attributes.put(AttributeKey.stringKey(Audit.TARGET_ID), targetId); + return this; + } + + @Override + public SdkAuditRecordBuilder setSource(String sourceId, String sourceType) { + attributes.put(AttributeKey.stringKey(Audit.SOURCE_TYPE), sourceType); + attributes.put(AttributeKey.stringKey(Audit.SOURCE_ID), sourceId); + return this; + } + + @Override + public SdkAuditRecordBuilder setBody(Value body) { + // Body is carried as a LogRecordData field via AuditRecordData.getBodyValue(); storing it + // separately here is not needed — the default implementation in AuditRecordData returns null. + return this; + } + + @Override + public SdkAuditRecordBuilder addAttribute(AttributeKey key, @Nullable T value) { + if (key == null || value == null) { + return this; + } + if (attributes.containsKey(key)) { + throw new IllegalStateException( + "Cannot add attribute with key '" + + key.getKey() + + "'; already exists on this record builder"); + } + attributes.put(key, value); + return this; + } + + /** + * FIXME ensure all values are set. + * + * @param event with all relevant information to build an AuditRecord + * @return SdkAuditRecordBuilder this + */ + public SdkAuditRecordBuilder setAuditEvent(AuditEvent event) { + Source src = event.getSource(); + Target tgt = event.getTarget(); + Actor actor = event.getActor(); + setSource(src.getName(), src.getType()); + setTarget(tgt.getName(), tgt.getType()); + setAction(event.getAction()); + try { + setActor(actor.getName(), ActorType.valueOf(actor.getType().toUpperCase(Locale.ROOT))); + } catch (IllegalArgumentException e) { + setActor(actor.getName(), ActorType.SYSTEM); + } + return this; + } + + @Override + public SdkAuditRecordBuilder setIntegrityValue(byte[] integrityValue) { + this.integrityValue = integrityValue; + return this; + } + + @Override + public SdkAuditRecordBuilder setSequenceNo(long sequenceNo) { + attributes.put(AttributeKey.longKey(Audit.SEQUENCE_NUMBER), sequenceNo); + return this; + } + + @Override + public SdkAuditRecordBuilder setPrevHash(String prevHash) { + attributes.put(AttributeKey.stringKey(Audit.PREV_HASH), prevHash); + return this; + } + + @Override + public AuditReceipt emit() { + if (provider.isShutdown()) { + throw new AuditDeliveryException( + "AuditProvider has been shut down; cannot emit audit records"); + } + + // Step 1: Generate RecordId if absent + String recordId = getRecordId(); + if (recordId == null || recordId.isEmpty()) { + setRecordId(UUID.randomUUID().toString()); + recordId = getRecordId(); + } + + // Step 2: Set ObservedTimestamp if absent + if (observedTimestampEpochNanos == 0) { + observedTimestampEpochNanos = provider.getClock().now(); + } + + // Step 3: Validate required fields + String actorId = getActorId(); + ActorType actorType = getActorType(); + String action = getAction(); + Outcome outcome = getOutcome(); + validateRequired("Timestamp", timestampEpochNanos != 0, "Timestamp must be set"); + validateRequired( + "EventName", + eventName != null && !eventName.isEmpty(), + "EventName must be set and non-empty"); + validateRequired( + "ActorId", actorId != null && !actorId.isEmpty(), "ActorId must be set and non-empty"); + validateRequired("ActorType", actorType != null, "ActorType must be set"); + validateRequired( + "Action", action != null && !action.isEmpty(), "Action must be set and non-empty"); + validateRequired("Outcome", outcome != null, "Outcome must be set"); + + String validatedEventName = Objects.requireNonNull(eventName); + String validatedRecordId = Objects.requireNonNull(recordId); + String validatedActorId = Objects.requireNonNull(actorId); + ActorType validatedActorType = Objects.requireNonNull(actorType); + String validatedAction = Objects.requireNonNull(action); + Outcome validatedOutcome = Objects.requireNonNull(outcome); + + String targetId = getTargetId(); + String targetType = getTargetType(); + String sourceId = getSourceId(); + String sourceType = getSourceType(); + long sequenceNo = getSequenceNo(); + String prevHash = getPrevHash(); + + // Step 4+5: Create the mutable record and pass it through all processors. + // Transfer ownership of the attributes map to the record (builder must not be reused). + AttributesMap recordAttributes = this.attributes; + this.attributes = + AttributesMap.create(0, 0); // invalidate; builder must not be reused after emit() + SdkReadWriteAuditRecord rwRecord = + new SdkReadWriteAuditRecord( + provider.getResource(), + loggerKey.getName(), + loggerKey.getVersion(), + loggerKey.getSchemaUrl(), + validatedRecordId, + timestampEpochNanos, + observedTimestampEpochNanos, + validatedEventName, + validatedActorId, + validatedActorType, + validatedAction, + validatedOutcome, + targetId, + targetType, + sourceId, + sourceType, + recordAttributes, + integrityValue, + sequenceNo, + prevHash, + schemaVersion); + + provider.getProcessor().onEmit(Context.current(), rwRecord); + + // Step 6+7: Retrieve and return the receipt set by the exporter-wrapping processor + AuditReceipt receipt = rwRecord.getReceipt(); + if (receipt == null) { + throw new AuditDeliveryException( + "Audit pipeline returned no receipt; ensure an AuditRecordExporter is configured"); + } + return receipt; + } + + private static void validateRequired(String field, boolean condition, String message) { + if (!condition) { + throw new IllegalArgumentException( + "AuditRecord validation failed for field '" + field + "': " + message); + } + } + + @Nullable + private String getRecordId() { + return attributes.get(AttributeKey.stringKey(Audit.RECORD_ID)); + } + + @Nullable + private String getActorId() { + return attributes.get(AttributeKey.stringKey(Audit.ACTOR_ID)); + } + + @Nullable + private ActorType getActorType() { + String actorType = attributes.get(AttributeKey.stringKey(Audit.ACTOR_TYPE)); + if (actorType == null) { + return null; + } + try { + return ActorType.valueOf(actorType); + } catch (IllegalArgumentException e) { + return null; + } + } + + @Nullable + private String getAction() { + return attributes.get(AttributeKey.stringKey(Audit.ACTION)); + } + + @Nullable + private Outcome getOutcome() { + String outcome = attributes.get(AttributeKey.stringKey(Audit.OUTCOME)); + if (outcome == null) { + return null; + } + try { + return Outcome.valueOf(outcome); + } catch (IllegalArgumentException e) { + return null; + } + } + + @Nullable + private String getTargetId() { + return attributes.get(AttributeKey.stringKey(Audit.TARGET_ID)); + } + + @Nullable + private String getTargetType() { + return attributes.get(AttributeKey.stringKey(Audit.TARGET_TYPE)); + } + + @Nullable + private String getSourceId() { + return attributes.get(AttributeKey.stringKey(Audit.SOURCE_ID)); + } + + @Nullable + private String getSourceType() { + return attributes.get(AttributeKey.stringKey(Audit.SOURCE_TYPE)); + } + + private long getSequenceNo() { + Long sequenceNo = attributes.get(AttributeKey.longKey(Audit.SEQUENCE_NUMBER)); + return sequenceNo == null ? -1 : sequenceNo; + } + + @Nullable + private String getPrevHash() { + return attributes.get(AttributeKey.stringKey(Audit.PREV_HASH)); + } +} diff --git a/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/SdkAuditRecordData.java b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/SdkAuditRecordData.java new file mode 100644 index 00000000000..3eb1a072d2b --- /dev/null +++ b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/SdkAuditRecordData.java @@ -0,0 +1,69 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.audit; + +import com.google.auto.value.AutoValue; +import io.opentelemetry.api.audit.ActorType; +import io.opentelemetry.api.audit.Outcome; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.resources.Resource; +import javax.annotation.Nullable; + +/** Immutable AutoValue implementation of {@link AuditRecordData}. */ +@AutoValue +@SuppressWarnings("AutoValueMutable") +public abstract class SdkAuditRecordData implements AuditRecordData { + + SdkAuditRecordData() {} + + /** Creates a new {@link SdkAuditRecordData}. */ + @SuppressWarnings("TooManyParameters") + public static SdkAuditRecordData create( + Resource resource, + long timestampEpochNanos, + long observedTimestampEpochNanos, + Attributes attributes, + String eventName, + String loggerName, + @Nullable String loggerVersion, + @Nullable String schemaUrl, + String recordId, + String actorId, + ActorType actorType, + String action, + Outcome outcome, + @Nullable String targetId, + @Nullable String targetType, + @Nullable String sourceId, + @Nullable String sourceType, + @Nullable byte[] integrityValue, + long sequenceNo, + @Nullable String prevHash, + @Nullable String schemaVersion) { + return new AutoValue_SdkAuditRecordData( + resource, + timestampEpochNanos, + observedTimestampEpochNanos, + attributes, + eventName, + loggerName, + loggerVersion, + schemaUrl, + recordId, + actorId, + actorType, + action, + outcome, + targetId, + targetType, + sourceId, + sourceType, + integrityValue, + sequenceNo, + prevHash, + schemaVersion); + } +} diff --git a/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/SdkReadWriteAuditRecord.java b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/SdkReadWriteAuditRecord.java new file mode 100644 index 00000000000..e196377a650 --- /dev/null +++ b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/SdkReadWriteAuditRecord.java @@ -0,0 +1,198 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.audit; + +import io.opentelemetry.api.audit.ActorType; +import io.opentelemetry.api.audit.AuditReceipt; +import io.opentelemetry.api.audit.Outcome; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.internal.GuardedBy; +import io.opentelemetry.sdk.common.internal.AttributesMap; +import io.opentelemetry.sdk.resources.Resource; +import javax.annotation.Nullable; +import javax.annotation.concurrent.ThreadSafe; + +/** + * Mutable view of an audit record passed to {@link AuditRecordProcessor#onEmit}. Processors MAY + * enrich the record by adding attributes but MUST NOT modify the mandatory fields. + */ +@ThreadSafe +public class SdkReadWriteAuditRecord implements ReadWriteAuditRecord { + + private final Resource resource; + private final String loggerName; + @Nullable private final String loggerVersion; + @Nullable private final String schemaUrl; + private final String recordId; + private final long timestampEpochNanos; + private final long observedTimestampEpochNanos; + private final String eventName; + private final String actorId; + private final ActorType actorType; + private final String action; + private final Outcome outcome; + @Nullable private final String targetId; + @Nullable private final String targetType; + @Nullable private final String sourceId; + @Nullable private final String sourceType; + @Nullable private final byte[] integrityValue; + private final long sequenceNo; + @Nullable private final String prevHash; + @Nullable private final String schemaVersion; + + private final Object lock = new Object(); + + @GuardedBy("lock") + @Nullable + private AttributesMap attributes; + + @GuardedBy("lock") + @Nullable + private AuditReceipt receipt; + + @SuppressWarnings("TooManyParameters") + SdkReadWriteAuditRecord( + Resource resource, + String loggerName, + @Nullable String loggerVersion, + @Nullable String schemaUrl, + String recordId, + long timestampEpochNanos, + long observedTimestampEpochNanos, + String eventName, + String actorId, + ActorType actorType, + String action, + Outcome outcome, + @Nullable String targetId, + @Nullable String targetType, + @Nullable String sourceId, + @Nullable String sourceType, + @Nullable AttributesMap attributes, + @Nullable byte[] integrityValue, + long sequenceNo, + @Nullable String prevHash, + @Nullable String schemaVersion) { + this.resource = resource; + this.loggerName = loggerName; + this.loggerVersion = loggerVersion; + this.schemaUrl = schemaUrl; + this.recordId = recordId; + this.timestampEpochNanos = timestampEpochNanos; + this.observedTimestampEpochNanos = observedTimestampEpochNanos; + this.eventName = eventName; + this.actorId = actorId; + this.actorType = actorType; + this.action = action; + this.outcome = outcome; + this.targetId = targetId; + this.targetType = targetType; + this.sourceId = sourceId; + this.sourceType = sourceType; + this.attributes = attributes; + this.integrityValue = integrityValue; + this.sequenceNo = sequenceNo; + this.prevHash = prevHash; + this.schemaVersion = schemaVersion; + } + + @Override + public ReadWriteAuditRecord setAttribute(AttributeKey key, @Nullable T value) { + if (key == null || value == null) { + return this; + } + synchronized (lock) { + if (attributes == null) { + attributes = AttributesMap.create(128, Integer.MAX_VALUE); + } + attributes.put(key, value); + } + return this; + } + + @Override + public void setReceipt(AuditReceipt receipt) { + synchronized (lock) { + this.receipt = receipt; + } + } + + @Override + @Nullable + public AuditReceipt getReceipt() { + synchronized (lock) { + return receipt; + } + } + + @Override + public AuditRecordData toAuditRecordData() { + Attributes frozenAttributes; + synchronized (lock) { + frozenAttributes = attributes != null ? attributes.immutableCopy() : Attributes.empty(); + } + return SdkAuditRecordData.create( + resource, + timestampEpochNanos, + observedTimestampEpochNanos, + frozenAttributes, + eventName, + loggerName, + loggerVersion, + schemaUrl, + recordId, + actorId, + actorType, + action, + outcome, + targetId, + targetType, + sourceId, + sourceType, + integrityValue, + sequenceNo, + prevHash, + schemaVersion); + } + + // ── Read accessors for processors ───────────────────────────────────────── + + @Override + public String getRecordId() { + return recordId; + } + + @Override + public long getTimestampEpochNanos() { + return timestampEpochNanos; + } + + @Override + public String getEventName() { + return eventName; + } + + @Override + public String getActorId() { + return actorId; + } + + @Override + public ActorType getActorType() { + return actorType; + } + + @Override + public String getAction() { + return action; + } + + @Override + public Outcome getOutcome() { + return outcome; + } +} diff --git a/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/export/BatchAuditRecordProcessor.java b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/export/BatchAuditRecordProcessor.java new file mode 100644 index 00000000000..995db2f9f5b --- /dev/null +++ b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/export/BatchAuditRecordProcessor.java @@ -0,0 +1,255 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.audit.export; + +import io.opentelemetry.api.audit.AuditDeliveryException; +import io.opentelemetry.api.audit.AuditReceipt; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.audit.AuditExportResult; +import io.opentelemetry.sdk.audit.AuditRecordData; +import io.opentelemetry.sdk.audit.AuditRecordExporter; +import io.opentelemetry.sdk.audit.AuditRecordProcessor; +import io.opentelemetry.sdk.audit.ReadWriteAuditRecord; +import io.opentelemetry.sdk.common.CompletableResultCode; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * An {@link AuditRecordProcessor} that batches records for efficient export while still blocking + * the calling thread until the batch containing the record is acknowledged by the audit sink. + * + *

Records are never dropped when the queue is full: the calling thread is blocked (back-pressure + * is applied to the application) until there is space in the queue or the configured timeout + * elapses. + * + *

Build via {@link BatchAuditRecordProcessorBuilder}: + * + *

{@code
+ * BatchAuditRecordProcessor processor = BatchAuditRecordProcessor.builder(exporter)
+ *     .setMaxQueueSize(4096)
+ *     .setScheduledDelayMillis(1000)
+ *     .build();
+ * }
+ */ +public final class BatchAuditRecordProcessor implements AuditRecordProcessor { + + private static final Logger logger = Logger.getLogger(BatchAuditRecordProcessor.class.getName()); + + private final AuditRecordExporter exporter; + private final int maxExportBatchSize; + private final long exportTimeoutMillis; + private final long scheduledDelayMillis; + private final int maxRetryCount; + private final long initialBackoffMillis; + + // Queue of pending records. BlockingQueue to apply back-pressure when full. + private final BlockingQueue queue; + + private final AtomicBoolean isShutdown = new AtomicBoolean(false); + private final AtomicReference flushRequested = new AtomicReference<>(); + private final BlockingQueue signal = new ArrayBlockingQueue<>(1); + + private final Thread worker; + + BatchAuditRecordProcessor( + AuditRecordExporter exporter, + int maxQueueSize, + int maxExportBatchSize, + long scheduledDelayMillis, + long exportTimeoutMillis, + int maxRetryCount, + long initialBackoffMillis) { + this.exporter = exporter; + this.maxExportBatchSize = maxExportBatchSize; + this.scheduledDelayMillis = scheduledDelayMillis; + this.exportTimeoutMillis = exportTimeoutMillis; + this.maxRetryCount = maxRetryCount; + this.initialBackoffMillis = initialBackoffMillis; + this.queue = new ArrayBlockingQueue<>(maxQueueSize); + this.worker = new Thread(new Worker(), "otel-audit-batch-worker"); + this.worker.setDaemon(true); + this.worker.start(); + } + + /** Returns a new {@link BatchAuditRecordProcessorBuilder} for the given exporter. */ + public static BatchAuditRecordProcessorBuilder builder(AuditRecordExporter exporter) { + return new BatchAuditRecordProcessorBuilder(exporter); + } + + @Override + public void onEmit(Context context, ReadWriteAuditRecord record) { + if (isShutdown.get()) { + throw new AuditDeliveryException( + "BatchAuditRecordProcessor has been shut down; refusing to emit record"); + } + CompletableFuture future = new CompletableFuture<>(); + PendingRecord pending = new PendingRecord(record.toAuditRecordData(), future); + // Block until there is space in the queue (back-pressure as per spec) + boolean offered = false; + try { + offered = queue.offer(pending, exportTimeoutMillis, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AuditDeliveryException("Interrupted while enqueuing audit record", e); + } + if (!offered) { + throw new AuditDeliveryException( + "Audit record queue is full and back-pressure timeout elapsed; record rejected"); + } + // Signal the worker in case it is waiting for work + signal.offer(Boolean.TRUE); + // Block until the worker exports the batch and completes the future + try { + AuditReceipt receipt = future.get(exportTimeoutMillis, TimeUnit.MILLISECONDS); + record.setReceipt(receipt); + } catch (TimeoutException e) { + future.cancel(false); + throw new AuditDeliveryException( + "Timed out waiting for audit export acknowledgement after " + exportTimeoutMillis + "ms", + e); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof AuditDeliveryException) { + throw (AuditDeliveryException) cause; + } + throw new AuditDeliveryException("Audit export failed", cause != null ? cause : e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AuditDeliveryException("Interrupted while waiting for audit export", e); + } + } + + @Override + public CompletableResultCode shutdown() { + if (!isShutdown.compareAndSet(false, true)) { + return CompletableResultCode.ofSuccess(); + } + CompletableResultCode result = forceFlush(); + result.whenComplete(exporter::shutdown); + return result; + } + + @Override + public CompletableResultCode forceFlush() { + CompletableResultCode flushResult = new CompletableResultCode(); + if (flushRequested.compareAndSet(null, flushResult)) { + signal.offer(Boolean.TRUE); + } + CompletableResultCode existing = flushRequested.get(); + return existing != null ? existing : flushResult; + } + + // ── Worker ──────────────────────────────────────────────────────────────── + + private final class Worker implements Runnable { + + @Override + public void run() { + List batch = new ArrayList<>(maxExportBatchSize); + while (!isShutdown.get()) { + try { + // Wait for work or the scheduled delay + signal.poll(scheduledDelayMillis, TimeUnit.MILLISECONDS); + exportBatch(batch); + CompletableResultCode flush = flushRequested.getAndSet(null); + if (flush != null) { + flush.succeed(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + // Drain remaining records on shutdown + exportBatch(batch); + } + + private void exportBatch(List batch) { + batch.clear(); + queue.drainTo(batch, maxExportBatchSize); + if (batch.isEmpty()) { + return; + } + List records = new ArrayList<>(batch.size()); + for (PendingRecord p : batch) { + records.add(p.data); + } + AuditExportResult result = exportWithRetry(records); + if (result.isSuccess()) { + List receipts = result.getReceipts(); + for (int i = 0; i < batch.size(); i++) { + AuditReceipt receipt = i < receipts.size() ? receipts.get(i) : null; + if (receipt != null) { + batch.get(i).future.complete(receipt); + } else { + batch + .get(i) + .future + .completeExceptionally( + new AuditDeliveryException("Exporter returned no receipt for record " + i)); + } + } + } else { + AuditDeliveryException ex = + new AuditDeliveryException( + "Audit export failed after " + maxRetryCount + " retries", + result.getFailure() != null + ? result.getFailure() + : new IllegalStateException("Audit export failed without cause")); + for (PendingRecord p : batch) { + p.future.completeExceptionally(ex); + } + } + } + + private AuditExportResult exportWithRetry(List records) { + long backoff = initialBackoffMillis; + for (int attempt = 0; attempt <= maxRetryCount; attempt++) { + AuditExportResult result = exporter.export(records); + if (result.isSuccess()) { + return result; + } + if (attempt < maxRetryCount) { + logger.log( + Level.WARNING, + "Audit export attempt {0} failed; retrying in {1}ms", + new Object[] {attempt + 1, backoff}); + try { + Thread.sleep(backoff); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return AuditExportResult.failure(e); + } + backoff = Math.min(backoff * 2, exportTimeoutMillis); + } + } + return AuditExportResult.failure( + new AuditDeliveryException("Max retries exhausted: " + maxRetryCount)); + } + } + + // ── Holder ──────────────────────────────────────────────────────────────── + + private static final class PendingRecord { + final AuditRecordData data; + final CompletableFuture future; + + PendingRecord(AuditRecordData data, CompletableFuture future) { + this.data = data; + this.future = future; + } + } +} diff --git a/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/export/BatchAuditRecordProcessorBuilder.java b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/export/BatchAuditRecordProcessorBuilder.java new file mode 100644 index 00000000000..e5b781d6a32 --- /dev/null +++ b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/export/BatchAuditRecordProcessorBuilder.java @@ -0,0 +1,99 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.audit.export; + +import static java.util.Objects.requireNonNull; + +import io.opentelemetry.sdk.audit.AuditRecordExporter; + +/** Builder for {@link BatchAuditRecordProcessor}. */ +public final class BatchAuditRecordProcessorBuilder { + + static final int DEFAULT_MAX_QUEUE_SIZE = 2048; + static final int DEFAULT_MAX_EXPORT_BATCH_SIZE = 512; + static final long DEFAULT_SCHEDULED_DELAY_MILLIS = 5_000; + static final long DEFAULT_EXPORT_TIMEOUT_MILLIS = 30_000; + static final int DEFAULT_MAX_RETRY_COUNT = 5; + static final long DEFAULT_INITIAL_BACKOFF_MILLIS = 1_000; + + private final AuditRecordExporter exporter; + private int maxQueueSize = DEFAULT_MAX_QUEUE_SIZE; + private int maxExportBatchSize = DEFAULT_MAX_EXPORT_BATCH_SIZE; + private long scheduledDelayMillis = DEFAULT_SCHEDULED_DELAY_MILLIS; + private long exportTimeoutMillis = DEFAULT_EXPORT_TIMEOUT_MILLIS; + private int maxRetryCount = DEFAULT_MAX_RETRY_COUNT; + private long initialBackoffMillis = DEFAULT_INITIAL_BACKOFF_MILLIS; + + BatchAuditRecordProcessorBuilder(AuditRecordExporter exporter) { + this.exporter = requireNonNull(exporter, "exporter"); + } + + /** + * Sets the maximum number of records held in the queue before back-pressure is applied to the + * calling thread. Default: {@value #DEFAULT_MAX_QUEUE_SIZE}. + */ + public BatchAuditRecordProcessorBuilder setMaxQueueSize(int maxQueueSize) { + this.maxQueueSize = maxQueueSize; + return this; + } + + /** + * Sets the maximum number of records per exported batch. Default: {@value + * #DEFAULT_MAX_EXPORT_BATCH_SIZE}. + */ + public BatchAuditRecordProcessorBuilder setMaxExportBatchSize(int maxExportBatchSize) { + this.maxExportBatchSize = maxExportBatchSize; + return this; + } + + /** + * Sets the delay in milliseconds between two consecutive exports when the batch is not full. + * Default: {@value #DEFAULT_SCHEDULED_DELAY_MILLIS} ms. + */ + public BatchAuditRecordProcessorBuilder setScheduledDelayMillis(long scheduledDelayMillis) { + this.scheduledDelayMillis = scheduledDelayMillis; + return this; + } + + /** + * Sets the maximum time in milliseconds allowed for a single export call before it is considered + * a failure. Default: {@value #DEFAULT_EXPORT_TIMEOUT_MILLIS} ms. + */ + public BatchAuditRecordProcessorBuilder setExportTimeoutMillis(long exportTimeoutMillis) { + this.exportTimeoutMillis = exportTimeoutMillis; + return this; + } + + /** + * Sets the maximum number of export retry attempts before surfacing a hard error. Default: + * {@value #DEFAULT_MAX_RETRY_COUNT}. + */ + public BatchAuditRecordProcessorBuilder setMaxRetryCount(int maxRetryCount) { + this.maxRetryCount = maxRetryCount; + return this; + } + + /** + * Sets the initial back-off delay in milliseconds between retries; doubled on each attempt. + * Default: {@value #DEFAULT_INITIAL_BACKOFF_MILLIS} ms. + */ + public BatchAuditRecordProcessorBuilder setInitialBackoffMillis(long initialBackoffMillis) { + this.initialBackoffMillis = initialBackoffMillis; + return this; + } + + /** Builds and returns the configured {@link BatchAuditRecordProcessor}. */ + public BatchAuditRecordProcessor build() { + return new BatchAuditRecordProcessor( + exporter, + maxQueueSize, + maxExportBatchSize, + scheduledDelayMillis, + exportTimeoutMillis, + maxRetryCount, + initialBackoffMillis); + } +} diff --git a/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/export/InMemoryAuditRecordExporter.java b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/export/InMemoryAuditRecordExporter.java new file mode 100644 index 00000000000..9e79537c637 --- /dev/null +++ b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/export/InMemoryAuditRecordExporter.java @@ -0,0 +1,90 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.audit.export; + +import io.opentelemetry.api.audit.AuditReceipt; +import io.opentelemetry.sdk.audit.AuditExportResult; +import io.opentelemetry.sdk.audit.AuditRecordData; +import io.opentelemetry.sdk.audit.AuditRecordExporter; +import io.opentelemetry.sdk.common.CompletableResultCode; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * An in-memory {@link AuditRecordExporter} for use in tests. + * + *

Stores all exported {@link AuditRecordData}s in a list and returns synthetic {@link + * AuditReceipt}s with empty integrity hashes (since there is no real audit sink). + * + *

{@code
+ * InMemoryAuditRecordExporter exporter = InMemoryAuditRecordExporter.create();
+ * SdkAuditProvider provider = SdkAuditProvider.builder()
+ *     .addAuditRecordProcessor(SimpleAuditRecordProcessor.create(exporter))
+ *     .build();
+ * // ...
+ * List records = exporter.getFinishedAuditRecords();
+ * }
+ */ +public final class InMemoryAuditRecordExporter implements AuditRecordExporter { + + private final List finishedRecords = new ArrayList<>(); + private final AtomicBoolean isShutdown = new AtomicBoolean(false); + private final Object lock = new Object(); + + private InMemoryAuditRecordExporter() {} + + /** Creates a new {@link InMemoryAuditRecordExporter}. */ + public static InMemoryAuditRecordExporter create() { + return new InMemoryAuditRecordExporter(); + } + + @Override + public AuditExportResult export(Collection records) { + if (isShutdown.get()) { + return AuditExportResult.failure(new IllegalStateException("Exporter has been shut down")); + } + List receipts = new ArrayList<>(records.size()); + synchronized (lock) { + for (AuditRecordData record : records) { + finishedRecords.add(record); + // Synthetic receipt: echo the recordId; integrity hash is empty (no real sink) + receipts.add(AuditReceipt.create(record.getRecordId(), "", 0)); + } + } + return AuditExportResult.success(receipts); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode shutdown() { + isShutdown.set(true); + return CompletableResultCode.ofSuccess(); + } + + /** + * Returns an unmodifiable snapshot of all exported {@link AuditRecordData}s in the order they + * were exported. + */ + public List getFinishedAuditRecords() { + synchronized (lock) { + return Collections.unmodifiableList(new ArrayList<>(finishedRecords)); + } + } + + /** Clears the list of finished records. */ + public void reset() { + synchronized (lock) { + finishedRecords.clear(); + } + } +} diff --git a/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/export/SimpleAuditRecordProcessor.java b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/export/SimpleAuditRecordProcessor.java new file mode 100644 index 00000000000..6087552b824 --- /dev/null +++ b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/export/SimpleAuditRecordProcessor.java @@ -0,0 +1,116 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.audit.export; + +import static java.util.Objects.requireNonNull; + +import io.opentelemetry.api.audit.AuditDeliveryException; +import io.opentelemetry.api.audit.AuditReceipt; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.audit.AuditExportResult; +import io.opentelemetry.sdk.audit.AuditRecordData; +import io.opentelemetry.sdk.audit.AuditRecordExporter; +import io.opentelemetry.sdk.audit.AuditRecordProcessor; +import io.opentelemetry.sdk.audit.ReadWriteAuditRecord; +import io.opentelemetry.sdk.common.CompletableResultCode; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * An {@link AuditRecordProcessor} that passes each {@link AuditRecordData} directly to the + * configured {@link AuditRecordExporter} synchronously on the calling thread. + * + *

This is the default processor for synchronous {@code emit()} calls. It guarantees that {@code + * emit()} blocks until the exporter has acknowledged the record and returns the {@link + * AuditReceipt} from the sink. + * + *

For high-volume scenarios, consider {@link BatchAuditRecordProcessor}. + */ +public final class SimpleAuditRecordProcessor implements AuditRecordProcessor { + + private static final Logger logger = Logger.getLogger(SimpleAuditRecordProcessor.class.getName()); + + private final AuditRecordExporter exporter; + private final Object exporterLock = new Object(); + private final AtomicBoolean isShutdown = new AtomicBoolean(false); + + private SimpleAuditRecordProcessor(AuditRecordExporter exporter) { + this.exporter = exporter; + } + + /** + * Creates a new {@link SimpleAuditRecordProcessor} that synchronously exports to the given {@link + * AuditRecordExporter}. + */ + public static SimpleAuditRecordProcessor create(AuditRecordExporter exporter) { + requireNonNull(exporter, "exporter"); + return new SimpleAuditRecordProcessor(exporter); + } + + @Override + public void onEmit(Context context, ReadWriteAuditRecord record) { + if (isShutdown.get()) { + throw new AuditDeliveryException( + "SimpleAuditRecordProcessor has been shut down; refusing to emit record"); + } + List batch = Collections.singletonList(record.toAuditRecordData()); + AuditExportResult result; + synchronized (exporterLock) { + result = exporter.export(batch); + } + if (!result.isSuccess()) { + Throwable cause = result.getFailure(); + String msg = "Audit record export failed"; + if (cause != null) { + throw new AuditDeliveryException(msg, cause); + } + throw new AuditDeliveryException(msg); + } + List receipts = result.getReceipts(); + if (receipts.isEmpty()) { + throw new AuditDeliveryException( + "Exporter returned success but no AuditReceipt; check exporter implementation"); + } + record.setReceipt(receipts.get(0)); + } + + @Override + public CompletableResultCode shutdown() { + if (isShutdown.getAndSet(true)) { + return CompletableResultCode.ofSuccess(); + } + CompletableResultCode result = new CompletableResultCode(); + CompletableResultCode shutdownResult = exporter.shutdown(); + shutdownResult.whenComplete( + () -> { + if (shutdownResult.isSuccess()) { + result.succeed(); + } else { + logger.log(Level.WARNING, "Exporter failed to shut down cleanly"); + result.fail(); + } + }); + return result; + } + + @Override + public CompletableResultCode forceFlush() { + return exporter.flush(); + } + + /** Returns the configured {@link AuditRecordExporter}. */ + public AuditRecordExporter getExporter() { + return exporter; + } + + @Override + public String toString() { + return "SimpleAuditRecordProcessor{exporter=" + exporter + '}'; + } +} diff --git a/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/package-info.java b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/package-info.java new file mode 100644 index 00000000000..ae58a6cbf1e --- /dev/null +++ b/sdk/audit/src/main/java/io/opentelemetry/sdk/audit/package-info.java @@ -0,0 +1,10 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** OpenTelemetry Audit Logging SDK. */ +@ParametersAreNonnullByDefault +package io.opentelemetry.sdk.audit; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/sdk/audit/src/test/java/io/opentelemetry/sdk/audit/SdkAuditProviderTest.java b/sdk/audit/src/test/java/io/opentelemetry/sdk/audit/SdkAuditProviderTest.java new file mode 100644 index 00000000000..f12e58387e8 --- /dev/null +++ b/sdk/audit/src/test/java/io/opentelemetry/sdk/audit/SdkAuditProviderTest.java @@ -0,0 +1,411 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.audit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.opentelemetry.api.audit.ActorType; +import io.opentelemetry.api.audit.AuditDeliveryException; +import io.opentelemetry.api.audit.AuditLogger; +import io.opentelemetry.api.audit.AuditReceipt; +import io.opentelemetry.api.audit.Outcome; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.sdk.audit.export.InMemoryAuditRecordExporter; +import io.opentelemetry.sdk.audit.export.SimpleAuditRecordProcessor; +import io.opentelemetry.sdk.resources.Resource; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class SdkAuditProviderTest { + + private InMemoryAuditRecordExporter exporter; + private SdkAuditProvider provider; + + @BeforeEach + void setUp() { + exporter = InMemoryAuditRecordExporter.create(); + provider = + SdkAuditProvider.builder() + .addAuditRecordProcessor(SimpleAuditRecordProcessor.create(exporter)) + .build(); + } + + @AfterEach + void tearDown() { + provider.close(); + } + + @Test + void emitMinimalRecord_returnsReceiptAndStoresRecord() { + AuditLogger logger = provider.get("com.example.auth"); + + AuditReceipt receipt = + logger + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setEventName("user.login.success") + .setActor("u8472", ActorType.USER) + .setAction("LOGIN") + .setOutcome(Outcome.SUCCESS) + .emit(); + + assertThat(receipt).isNotNull(); + assertThat(receipt.recordId()).isNotEmpty(); + + List records = exporter.getFinishedAuditRecords(); + assertThat(records).hasSize(1); + AuditRecordData data = records.get(0); + assertThat(data.getEventName()).isEqualTo("user.login.success"); + assertThat(data.getActorId()).isEqualTo("u8472"); + assertThat(data.getActorType()).isEqualTo(ActorType.USER); + assertThat(data.getAction()).isEqualTo("LOGIN"); + assertThat(data.getOutcome()).isEqualTo(Outcome.SUCCESS); + } + + @Test + void emit_autoGeneratesRecordId_whenCallerOmitsIt() { + AuditLogger logger = provider.get("com.example.test"); + AuditReceipt receipt = + logger + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setEventName("config.change") + .setActor("svc-deployer", ActorType.SERVICE) + .setAction("UPDATE") + .setOutcome(Outcome.SUCCESS) + .emit(); + + assertThat(receipt.recordId()).isNotEmpty(); + List records = exporter.getFinishedAuditRecords(); + assertThat(records.get(0).getRecordId()).isEqualTo(receipt.recordId()); + } + + @Test + void emit_stableRecordId_whenCallerSetsIt() { + AuditLogger logger = provider.get("com.example.test"); + String fixedId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + logger + .auditRecordBuilder() + .setRecordId(fixedId) + .setTimestamp(Instant.now()) + .setEventName("file.read") + .setActor("u1", ActorType.USER) + .setAction("READ") + .setOutcome(Outcome.SUCCESS) + .emit(); + + assertThat(exporter.getFinishedAuditRecords().get(0).getRecordId()).isEqualTo(fixedId); + } + + @Test + void emit_setsObservedTimestamp_whenCallerOmitsIt() { + AuditLogger logger = provider.get("com.example.test"); + logger + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setEventName("data.delete") + .setActor("u2", ActorType.USER) + .setAction("DELETE") + .setOutcome(Outcome.SUCCESS) + .emit(); + + AuditRecordData data = exporter.getFinishedAuditRecords().get(0); + assertThat(data.getObservedTimestampEpochNanos()).isGreaterThan(0); + } + + @Test + void emit_failsHard_whenTimestampMissing() { + AuditLogger logger = provider.get("com.example.test"); + assertThatThrownBy( + () -> + logger + .auditRecordBuilder() + .setEventName("user.login") + .setActor("u1", ActorType.USER) + .setAction("LOGIN") + .setOutcome(Outcome.SUCCESS) + .emit()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Timestamp"); + } + + @Test + void emit_failsHard_whenEventNameMissing() { + AuditLogger logger = provider.get("com.example.test"); + assertThatThrownBy( + () -> + logger + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setActor("u1", ActorType.USER) + .setAction("LOGIN") + .setOutcome(Outcome.SUCCESS) + .emit()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("EventName"); + } + + @Test + void emit_failsHard_whenActorIdMissing() { + AuditLogger logger = provider.get("com.example.test"); + assertThatThrownBy( + () -> + logger + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setEventName("user.login") + // actor omitted — no setActor() call + .setAction("LOGIN") + .setOutcome(Outcome.SUCCESS) + .emit()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("ActorId"); + } + + @Test + void emit_failsHard_whenActorTypeMissing() { + // ActorType is always provided alongside ActorId via setActor(); this test + // verifies that omitting setActor() entirely still fails with an ActorType message. + AuditLogger logger = provider.get("com.example.test"); + assertThatThrownBy( + () -> + logger + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setEventName("user.login") + // actor omitted — no setActor() call + .setAction("LOGIN") + .setOutcome(Outcome.SUCCESS) + .emit()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Actor"); + } + + @Test + void emit_failsHard_whenActionMissing() { + AuditLogger logger = provider.get("com.example.test"); + assertThatThrownBy( + () -> + logger + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setEventName("user.login") + .setActor("u1", ActorType.USER) + .setOutcome(Outcome.SUCCESS) + .emit()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Action"); + } + + @Test + void emit_failsHard_whenOutcomeMissing() { + AuditLogger logger = provider.get("com.example.test"); + assertThatThrownBy( + () -> + logger + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setEventName("user.login") + .setActor("u1", ActorType.USER) + .setAction("LOGIN") + .emit()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Outcome"); + } + + @Test + void emit_failsHard_afterShutdown() { + AuditLogger logger = provider.get("com.example.test"); + provider.shutdown().join(5, TimeUnit.SECONDS); + + assertThatThrownBy( + () -> + logger + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setEventName("user.login") + .setActor("u1", ActorType.USER) + .setAction("LOGIN") + .setOutcome(Outcome.SUCCESS) + .emit()) + .isInstanceOf(AuditDeliveryException.class) + .hasMessageContaining("shut down"); + } + + @Test + void getAuditLogger_failsHard_afterShutdown() { + provider.shutdown().join(5, TimeUnit.SECONDS); + assertThatThrownBy(() -> provider.get("x")) + .isInstanceOf(AuditDeliveryException.class) + .hasMessageContaining("shut down"); + } + + @Test + void emit_setsOptionalFields() { + AuditLogger logger = provider.get("com.example.test"); + logger + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setEventName("resource.access") + .setActor("svc-1", ActorType.SERVICE) + .setAction("READ") + .setOutcome(Outcome.SUCCESS) + .setTarget("/api/data/123", "http.endpoint") + .setSource("10.0.0.1", "ipv4") + .setSchemaVersion("1.0.0") + .setBody("additional context") + .emit(); + + AuditRecordData data = exporter.getFinishedAuditRecords().get(0); + assertThat(data.getTargetId()).isEqualTo("/api/data/123"); + assertThat(data.getTargetType()).isEqualTo("http.endpoint"); + assertThat(data.getSourceId()).isEqualTo("10.0.0.1"); + assertThat(data.getSourceType()).isEqualTo("ipv4"); + assertThat(data.getSchemaVersion()).isEqualTo("1.0.0"); + // body is not stored on AuditRecordData; setBody() is a no-op per spec + } + + @Test + void sameLoggerReturnedForSameName() { + AuditLogger l1 = provider.get("com.example.auth"); + AuditLogger l2 = provider.get("com.example.auth"); + assertThat(l1).isSameAs(l2); + } + + @Test + void resourceAttributesCarriedToRecord() { + Resource resource = + Resource.builder().put("service.name", "audit-svc").put("service.version", "1.0").build(); + InMemoryAuditRecordExporter exp = InMemoryAuditRecordExporter.create(); + try (SdkAuditProvider p = + SdkAuditProvider.builder() + .setResource(resource) + .addAuditRecordProcessor(SimpleAuditRecordProcessor.create(exp)) + .build()) { + p.get("test") + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setEventName("test.event") + .setActor("u1", ActorType.USER) + .setAction("READ") + .setOutcome(Outcome.SUCCESS) + .emit(); + } + Resource actual = exp.getFinishedAuditRecords().get(0).getResource(); + assertThat(actual.getAttribute(AttributeKey.stringKey("service.name"))).isEqualTo("audit-svc"); + assertThat(actual.getAttribute(AttributeKey.stringKey("service.version"))).isEqualTo("1.0"); + } + + @Test + void integrityAlgorithmStoredAsResourceAttribute() { + InMemoryAuditRecordExporter exp = InMemoryAuditRecordExporter.create(); + try (SdkAuditProvider p = + SdkAuditProvider.builder() + .setIntegrityAlgorithm("HMAC-SHA256") + .addAuditRecordProcessor(SimpleAuditRecordProcessor.create(exp)) + .build()) { + p.get("test") + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setEventName("test.event") + .setActor("u1", ActorType.USER) + .setAction("READ") + .setOutcome(Outcome.SUCCESS) + .emit(); + } + String algo = + exp.getFinishedAuditRecords() + .get(0) + .getResource() + .getAttribute(AttributeKey.stringKey("audit.integrity.algorithm")); + assertThat(algo).isEqualTo("HMAC-SHA256"); + } + + @Test + void processorCanEnrichRecord() { + InMemoryAuditRecordExporter exp = InMemoryAuditRecordExporter.create(); + AuditRecordProcessor enricher = + (ctx, record) -> + record.setAttribute(AttributeKey.stringKey("enriched.by"), "test-processor"); + try (SdkAuditProvider p = + SdkAuditProvider.builder() + .addAuditRecordProcessor(enricher) + .addAuditRecordProcessor(SimpleAuditRecordProcessor.create(exp)) + .build()) { + p.get("test") + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setEventName("test.event") + .setActor("u1", ActorType.USER) + .setAction("READ") + .setOutcome(Outcome.SUCCESS) + .emit(); + } + AuditRecordData data = exp.getFinishedAuditRecords().get(0); + assertThat(data.getAttributes().get(AttributeKey.stringKey("enriched.by"))) + .isEqualTo("test-processor"); + } + + @Test + void multipleRecords_allDelivered() { + AuditLogger logger = provider.get("com.example.bulk"); + int count = 10; + for (int i = 0; i < count; i++) { + logger + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setEventName("bulk.event") + .setActor("u" + i, ActorType.USER) + .setAction("READ") + .setOutcome(Outcome.SUCCESS) + .emit(); + } + assertThat(exporter.getFinishedAuditRecords()).hasSize(count); + } + + @Test + void sequenceNoAndPrevHash_storedOnRecord() { + AuditLogger logger = provider.get("com.example.test"); + String prevHash = "deadbeef1234"; + logger + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setEventName("chain.event") + .setActor("u1", ActorType.USER) + .setAction("READ") + .setOutcome(Outcome.SUCCESS) + .setSequenceNo(42L) + .setPrevHash(prevHash) + .emit(); + + AuditRecordData data = exporter.getFinishedAuditRecords().get(0); + assertThat(data.getSequenceNo()).isEqualTo(42L); + assertThat(data.getPrevHash()).isEqualTo(prevHash); + } + + @Test + void integrityValue_storedOnRecord() { + AuditLogger logger = provider.get("com.example.test"); + byte[] proof = new byte[] {0x01, 0x02, 0x03}; + logger + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setEventName("signed.event") + .setActor("svc", ActorType.SERVICE) + .setAction("CREATE") + .setOutcome(Outcome.SUCCESS) + .setIntegrityValue(proof) + .emit(); + + AuditRecordData data = exporter.getFinishedAuditRecords().get(0); + assertThat(data.getIntegrityValue()).containsExactly(0x01, 0x02, 0x03); + } +} diff --git a/sdk/audit/src/test/java/io/opentelemetry/sdk/audit/export/BatchAuditRecordProcessorTest.java b/sdk/audit/src/test/java/io/opentelemetry/sdk/audit/export/BatchAuditRecordProcessorTest.java new file mode 100644 index 00000000000..3640afd54f0 --- /dev/null +++ b/sdk/audit/src/test/java/io/opentelemetry/sdk/audit/export/BatchAuditRecordProcessorTest.java @@ -0,0 +1,145 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.audit.export; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.opentelemetry.api.audit.ActorType; +import io.opentelemetry.api.audit.AuditReceipt; +import io.opentelemetry.api.audit.Outcome; +import io.opentelemetry.sdk.audit.AuditRecordData; +import io.opentelemetry.sdk.audit.SdkAuditProvider; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class BatchAuditRecordProcessorTest { + + private InMemoryAuditRecordExporter exporter; + private BatchAuditRecordProcessor processor; + private SdkAuditProvider provider; + + @BeforeEach + void setUp() { + exporter = InMemoryAuditRecordExporter.create(); + processor = + BatchAuditRecordProcessor.builder(exporter) + .setScheduledDelayMillis(100) + .setMaxQueueSize(1000) + .setMaxExportBatchSize(50) + .build(); + provider = SdkAuditProvider.builder().addAuditRecordProcessor(processor).build(); + } + + @AfterEach + void tearDown() { + provider.close(); + } + + @Test + void batchExports_allRecordsDelivered() throws InterruptedException { + int count = 20; + CountDownLatch latch = new CountDownLatch(count); + + for (int i = 0; i < count; i++) { + int idx = i; + Thread t = + new Thread( + () -> { + provider + .get("test") + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setEventName("batch.event") + .setActor("u" + idx, ActorType.USER) + .setAction("READ") + .setOutcome(Outcome.SUCCESS) + .emit(); + latch.countDown(); + }); + t.start(); + } + + assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(exporter.getFinishedAuditRecords()).hasSize(count); + } + + @Test + void forceFlush_exportsAllPendingRecords() { + int count = 5; + for (int i = 0; i < count; i++) { + provider + .get("test") + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setEventName("flush.event") + .setActor("u" + i, ActorType.USER) + .setAction("READ") + .setOutcome(Outcome.SUCCESS) + .emit(); + } + provider.forceFlush().join(5, TimeUnit.SECONDS); + assertThat(exporter.getFinishedAuditRecords()).hasSize(count); + } + + @Test + void receipts_returnedToCallers() { + for (int i = 0; i < 3; i++) { + AuditReceipt receipt = + provider + .get("test") + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setEventName("receipt.event") + .setActor("u" + i, ActorType.USER) + .setAction("READ") + .setOutcome(Outcome.SUCCESS) + .emit(); + assertThat(receipt).isNotNull(); + assertThat(receipt.recordId()).isNotEmpty(); + } + } + + @Test + void recordIds_areUnique_acrossBatch() { + int count = 10; + for (int i = 0; i < count; i++) { + provider + .get("test") + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setEventName("unique.event") + .setActor("u" + i, ActorType.USER) + .setAction("READ") + .setOutcome(Outcome.SUCCESS) + .emit(); + } + List records = exporter.getFinishedAuditRecords(); + long distinctIds = records.stream().map(AuditRecordData::getRecordId).distinct().count(); + assertThat(distinctIds).isEqualTo(count); + } + + @Test + void shutdown_exportsRemainingRecords() { + for (int i = 0; i < 3; i++) { + provider + .get("test") + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setEventName("shutdown.event") + .setActor("u" + i, ActorType.USER) + .setAction("READ") + .setOutcome(Outcome.SUCCESS) + .emit(); + } + provider.shutdown().join(10, TimeUnit.SECONDS); + assertThat(exporter.getFinishedAuditRecords()).hasSize(3); + } +} diff --git a/sdk/audit/src/test/java/io/opentelemetry/sdk/audit/export/SimpleAuditRecordProcessorTest.java b/sdk/audit/src/test/java/io/opentelemetry/sdk/audit/export/SimpleAuditRecordProcessorTest.java new file mode 100644 index 00000000000..40ca20bd93c --- /dev/null +++ b/sdk/audit/src/test/java/io/opentelemetry/sdk/audit/export/SimpleAuditRecordProcessorTest.java @@ -0,0 +1,156 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.audit.export; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.opentelemetry.api.audit.ActorType; +import io.opentelemetry.api.audit.AuditDeliveryException; +import io.opentelemetry.api.audit.AuditLogger; +import io.opentelemetry.api.audit.AuditReceipt; +import io.opentelemetry.api.audit.Outcome; +import io.opentelemetry.sdk.audit.AuditExportResult; +import io.opentelemetry.sdk.audit.AuditRecordData; +import io.opentelemetry.sdk.audit.AuditRecordExporter; +import io.opentelemetry.sdk.audit.SdkAuditProvider; +import io.opentelemetry.sdk.common.CompletableResultCode; +import java.time.Instant; +import java.util.Collection; +import java.util.Collections; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class SimpleAuditRecordProcessorTest { + + @Test + void exportsRecordSynchronously() { + InMemoryAuditRecordExporter exporter = InMemoryAuditRecordExporter.create(); + try (SdkAuditProvider provider = + SdkAuditProvider.builder() + .addAuditRecordProcessor(SimpleAuditRecordProcessor.create(exporter)) + .build()) { + + provider + .get("test") + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setEventName("simple.test") + .setActor("u1", ActorType.USER) + .setAction("READ") + .setOutcome(Outcome.SUCCESS) + .emit(); + + assertThat(exporter.getFinishedAuditRecords()).hasSize(1); + } + } + + @Test + void throwsDeliveryException_onExporterFailure() { + AuditRecordExporter failingExporter = + new AuditRecordExporter() { + @Override + public AuditExportResult export(Collection records) { + return AuditExportResult.failure(new RuntimeException("sink unavailable")); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode shutdown() { + return CompletableResultCode.ofSuccess(); + } + }; + + try (SdkAuditProvider provider = + SdkAuditProvider.builder() + .addAuditRecordProcessor(SimpleAuditRecordProcessor.create(failingExporter)) + .build()) { + AuditLogger logger = provider.get("test"); + assertThatThrownBy( + () -> + logger + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setEventName("fail.test") + .setActor("u1", ActorType.USER) + .setAction("READ") + .setOutcome(Outcome.FAILURE) + .emit()) + .isInstanceOf(AuditDeliveryException.class); + } + } + + @Test + void throwsDeliveryException_afterShutdown() { + InMemoryAuditRecordExporter exporter = InMemoryAuditRecordExporter.create(); + SimpleAuditRecordProcessor processor = SimpleAuditRecordProcessor.create(exporter); + try (SdkAuditProvider provider = + SdkAuditProvider.builder().addAuditRecordProcessor(processor).build()) { + provider.shutdown().join(5, TimeUnit.SECONDS); + + assertThatThrownBy( + () -> + provider + .get("test") + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setEventName("after.shutdown") + .setActor("u1", ActorType.USER) + .setAction("READ") + .setOutcome(Outcome.SUCCESS) + .emit()) + .isInstanceOf(AuditDeliveryException.class); + } + } + + @Test + void exporterCalledOnce_perEmit() { + AtomicInteger exportCallCount = new AtomicInteger(0); + AuditRecordExporter countingExporter = + new AuditRecordExporter() { + @Override + public AuditExportResult export(Collection records) { + exportCallCount.incrementAndGet(); + return AuditExportResult.success( + Collections.singletonList( + AuditReceipt.create(records.iterator().next().getRecordId(), "", 0))); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode shutdown() { + return CompletableResultCode.ofSuccess(); + } + }; + + try (SdkAuditProvider provider = + SdkAuditProvider.builder() + .addAuditRecordProcessor(SimpleAuditRecordProcessor.create(countingExporter)) + .build()) { + for (int i = 0; i < 5; i++) { + provider + .get("test") + .auditRecordBuilder() + .setTimestamp(Instant.now()) + .setEventName("count.event") + .setActor("u" + i, ActorType.USER) + .setAction("READ") + .setOutcome(Outcome.SUCCESS) + .emit(); + } + assertThat(exportCallCount.get()).isEqualTo(5); + } + } +} diff --git a/sdk/build.gradle.kts b/sdk/build.gradle.kts index 72ea1f8e323..6d26bf381dc 100644 --- a/sdk/build.gradle.kts +++ b/sdk/build.gradle.kts @@ -1,6 +1,6 @@ subprojects { // Workaround https://github.com/gradle/gradle/issues/847 - group = "io.opentelemetry.sdk" + group = "eu.apeirora.opentelemetry.sdk" val proj = this plugins.withId("java") { configure { diff --git a/settings.gradle.kts b/settings.gradle.kts index ecbfc83fdeb..1d7c2f8ab35 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,6 +20,15 @@ dependencyResolutionManagement { mavenCentral() google() mavenLocal() + maven { + url = uri("https://maven.pkg.github.com/apeirora/AuditAPI") + credentials { + username = System.getenv("GITHUB_ACTOR") + ?: (settings.extra.properties["GITHUB_ACTOR"] as? String) ?: "" + password = System.getenv("GITHUB_TOKEN") + ?: (settings.extra.properties["GITHUB_TOKEN"] as? String) ?: "" + } + } } } @@ -44,6 +53,7 @@ include(":exporters:sender:okhttp") include(":exporters:logging") include(":exporters:logging-otlp") include(":exporters:otlp:all") +include(":exporters:otlp:audit") include(":exporters:otlp:common") include(":exporters:otlp:profiles") include(":exporters:otlp:testing-internal") @@ -61,6 +71,7 @@ include(":opentelemetry-jfr-profiles-shim") include(":opentracing-shim") include(":perf-harness") include(":sdk:all") +include(":sdk:audit") include(":sdk:common") include(":sdk:logs") include(":sdk:metrics")