Skip to content

Commit 68b552b

Browse files
docs: Fix misleading claims — outbox auto-config, tracer example, missing properties (#104)
* feat: Implement OpenTelemetry ScimTracer + fix misleading docs (#105) OpenTelemetryScimTracer auto-configures when OpenTelemetry is on the classpath and a Tracer bean is available: - Wraps SCIM operations in spans with attributes - Records exceptions with StatusCode.ERROR - Returns trace ID as correlation ID for MDC and events - Backs off when custom ScimTracer bean provided - Disabled via scim.tracing.enabled=false Devil's Advocate fixes: - HIGH: Nested @configuration for double classpath safety - MEDIUM: scope.close() before span.end() (correct OTel ordering) - MEDIUM: Fixed docs reference to non-existent Spring Boot starter - MEDIUM: Added auto-configuration tests (bean creation + property gate) Also fixes: - outbox-pattern.md: Removed false auto-config claim - README.md: Added scim.idp.claims.* and scim.tracing.enabled properties - observability.md: Real feature docs replacing pseudocode Tests: 6 unit + 2 auto-config tests Closes #105 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: Add OpenTelemetry tracing to fullstack Spring sample - Add opentelemetry-api, opentelemetry-sdk, opentelemetry-exporter-otlp deps - Add TracingConfig.java creating OTel Tracer bean with OTLP/gRPC exporter - Add Jaeger service to docker-compose.yml (observability profile) - Add OTEL_EXPORTER_OTLP_ENDPOINT env var to backend service - Update services table with Prometheus, Grafana, and Jaeger rows Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b0590a7 commit 68b552b

13 files changed

Lines changed: 415 additions & 39 deletions

File tree

README.md

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ A modern, Kotlin-first SCIM 2.0 (RFC 7643/7644) SDK for the JVM with full Java i
1616
- **Spring Boot starter**: Auto-configuration with sensible defaults
1717
- **Works without Spring**: Use with any JVM HTTP framework
1818
- **OOTB persistence**: JPA adapter with reference schemas for PostgreSQL, MySQL, Oracle, MSSQL, H2
19-
- **Observability**: Metrics (Micrometer), tracing, structured logging, event system
19+
- **Observability**: Metrics (Micrometer), tracing (OpenTelemetry), structured logging (MDC), event system
2020
- **Type-safe client**: Fluent API with Kotlin DSLs for filters, patches, searches
2121
- **RFC 9457 ProblemDetail**: Content-negotiated error responses
2222
- **Extensible**: SPI for serialization, HTTP transport, identity, authorization, events
@@ -215,13 +215,16 @@ var publisher = new CompositeEventPublisher(outboundPublisher, auditPublisher);
215215

216216
## Observability
217217

218-
The SDK provides built-in observability via [Micrometer](https://micrometer.io/) metrics, structured logging, and event correlation.
218+
The SDK provides built-in observability:
219219

220-
See the [Observability Guide](docs/observability.md) for details.
220+
- **Metrics** — [Micrometer](https://micrometer.io/) auto-configured with Prometheus, Grafana dashboard included
221+
- **Tracing** — [OpenTelemetry](https://opentelemetry.io/) auto-configured when on classpath (spans, trace IDs, exception recording)
222+
- **Structured logging** — SLF4J MDC with `scim.correlationId`, `scim.operation`, `scim.resourceType`
223+
- **Event correlation** — all events carry the tracer's correlation ID
221224

222-
### Quick Start (Spring Boot)
225+
See the [Observability Guide](docs/observability.md) for configuration, metrics reference, and custom implementation examples.
223226

224-
Add Micrometer and actuator:
227+
### Quick Start (Spring Boot)
225228

226229
```yaml
227230
management:
@@ -231,7 +234,7 @@ management:
231234
include: health,prometheus,metrics
232235
```
233236

234-
Metrics are automatically recorded for all SCIM operations. See the [Spring Boot full-stack sample](scim2-sdk-samples/sample-fullstack-spring/) for a complete example with Prometheus + Grafana.
237+
Metrics and tracing are automatically recorded for all SCIM operations. See the [Spring Boot full-stack sample](scim2-sdk-samples/sample-fullstack-spring/) for a complete example with Prometheus + Grafana.
235238

236239
## Sample Applications
237240

@@ -278,12 +281,19 @@ All properties are optional with sensible defaults:
278281
| `scim.persistence.table-name` | `scim_resources` | Database table name for SCIM resource storage |
279282
| `scim.persistence.schema-name` | *(none)* | Database schema name (e.g., `scim`) — if set, the table is qualified as `schema.table` |
280283
| `scim.persistence.auto-migrate` | `false` | Run [Flyway](https://flywaydb.org/) migration on startup to create the `scim_resources` table automatically |
284+
| `scim.tracing.enabled` | `true` | Enable OpenTelemetry tracing auto-configuration. Set to `false` to disable even when OpenTelemetry is on the classpath |
281285
| `scim.client.base-url` | *(none)* | Base URL of the remote SCIM Service Provider — when set, auto-configures a `ScimClient` bean |
282286
| `scim.client.connect-timeout` | `10s` | TCP connection timeout for the SCIM client |
283287
| `scim.client.read-timeout` | `30s` | Read timeout for the SCIM client |
284288
| `scim.idp.provider` | *(none)* | Identity Provider type: `keycloak`, `okta`, `azure-ad`, `ping-federate`, `auth0` — auto-configures the corresponding `IdentityResolver` |
285289
| `scim.idp.client-id` | *(none)* | Client ID for Keycloak client-role extraction |
286290
| `scim.idp.namespace` | *(none)* | Auth0 custom namespace for role claims |
291+
| `scim.idp.claims.subject` | `sub` | JWT claim for subject |
292+
| `scim.idp.claims.email` | `email` | JWT claim for email |
293+
| `scim.idp.claims.name` | `name` | JWT claim for name |
294+
| `scim.idp.claims.roles` | `roles` | JWT claim for roles |
295+
| `scim.idp.claims.groups` | `groups` | JWT claim for groups |
296+
| `scim.idp.claims.custom` | `{}` | Additional custom claim mappings (key-value pairs) |
287297

288298
## Database Support
289299

docs/observability.md

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -200,30 +200,52 @@ With Spring Boot, register it as a bean and the auto-configuration will use it i
200200
fun scimMetrics(): ScimMetrics = DatadogScimMetrics(statsdClient)
201201
```
202202

203+
### OpenTelemetry Tracing
204+
205+
The SDK includes `OpenTelemetryScimTracer`, which auto-configures when OpenTelemetry is on the classpath. Add the dependency:
206+
207+
```xml
208+
<dependency>
209+
<groupId>io.opentelemetry</groupId>
210+
<artifactId>opentelemetry-api</artifactId>
211+
</dependency>
212+
```
213+
214+
When an `io.opentelemetry.api.trace.Tracer` bean is available (e.g., via OpenTelemetry's Spring Boot instrumentation or a custom `Tracer` bean), the auto-configuration registers an `OpenTelemetryScimTracer` that:
215+
216+
- Wraps every SCIM operation in an OpenTelemetry span with attributes
217+
- Records exceptions with `StatusCode.ERROR` and `recordException`
218+
- Provides the current trace ID as the correlation ID for MDC and events
219+
220+
No additional configuration is needed. To disable tracing while keeping OpenTelemetry on the classpath:
221+
222+
```yaml
223+
scim:
224+
tracing:
225+
enabled: false
226+
```
227+
228+
The `@ConditionalOnMissingBean` guard means you can still provide your own `ScimTracer` bean to override it.
229+
203230
### Custom ScimTracer
204231

205-
To provide your own tracer (e.g., for OpenTelemetry spans), implement `ScimTracer`:
232+
To provide a custom tracer implementation, implement the `ScimTracer` interface:
206233

207234
```kotlin
208-
class OpenTelemetryScimTracer(private val tracer: Tracer) : ScimTracer {
235+
class MyCustomTracer : ScimTracer {
209236
override fun <T> trace(operationName: String, attributes: Map<String, String>, block: () -> T): T {
210-
val span = tracer.spanBuilder(operationName).startSpan()
211-
attributes.forEach { (k, v) -> span.setAttribute(k, v) }
212-
return span.makeCurrent().use {
213-
try {
214-
block()
215-
} finally {
216-
span.end()
217-
}
218-
}
237+
// your tracing logic
238+
return block()
219239
}
220240
221-
override fun currentCorrelationId(): String? =
222-
Span.current().spanContext.traceId.takeIf { it != TraceId.getInvalid() }
241+
override fun currentCorrelationId(): String? {
242+
// return your correlation ID
243+
return null
244+
}
223245
}
224246
```
225247

226-
Register it as a Spring bean or pass it directly to `ScimEndpointDispatcher`.
248+
Register it as a Spring bean and the auto-configuration will use it instead of `OpenTelemetryScimTracer` (thanks to `@ConditionalOnMissingBean`).
227249

228250
### Disabling Metrics
229251

docs/outbox-pattern.md

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,11 @@ sequenceDiagram
127127

128128
## Using the Outbox Pattern
129129

130-
### Option 1: namastack-outbox (Recommended)
130+
### Option 1: namastack-outbox (Recommended Pattern)
131131

132-
[namastack-outbox](https://github.com/namastack/namastack-outbox) provides transactional outbox support and is available in Maven Central. It integrates with Spring Modulith's event externalization, meaning events stored via the outbox can be automatically forwarded to Kafka, AMQP, or any other supported broker through Spring Modulith's `EventExternalizationConfiguration`.
132+
> **Note:** This is a documented integration pattern, not a built-in SDK feature.
133+
134+
[namastack-outbox](https://github.com/namastack/namastack-outbox) provides transactional outbox support and is available in Maven Central. It integrates with Spring Modulith's event externalization, meaning events stored via the outbox can be forwarded to Kafka, AMQP, or any other supported broker through Spring Modulith's `EventExternalizationConfiguration`.
133135

134136
Add the dependency:
135137

@@ -141,15 +143,29 @@ Add the dependency:
141143
</dependency>
142144
```
143145

144-
Configure in `application.yml`:
146+
Implement `ScimEventPublisher` as a Spring `@Component`. The SDK auto-detects it and uses it instead of the default `SpringScimEventPublisher`:
145147

146-
```yaml
147-
scim:
148-
outbox:
149-
enabled: true
148+
```kotlin
149+
@Component
150+
class NamastackOutboxAdapter(
151+
private val outboxService: OutboxService, // from namastack-outbox
152+
private val objectMapper: ObjectMapper,
153+
) : ScimEventPublisher {
154+
override fun publish(event: ScimEvent) {
155+
outboxService.store(
156+
OutboxEvent(
157+
id = event.eventId,
158+
type = event::class.simpleName!!,
159+
payload = objectMapper.writeValueAsString(event),
160+
)
161+
)
162+
}
163+
}
150164
```
151165

152-
The SDK auto-configures a `NamastackOutboxAdapter` that delegates to namastack-outbox's event publishing. Events are stored transactionally alongside your resource changes and published asynchronously.
166+
That's it — Spring auto-configuration picks up your `@Component` automatically via `@ConditionalOnMissingBean(ScimEventPublisher::class)`. Events are then stored transactionally alongside your resource changes and published asynchronously by namastack-outbox's poller.
167+
168+
For plain Java (without Spring), wire the publisher manually into `ScimEndpointDispatcher`.
153169

154170
### Option 2: Custom Implementation
155171

scim2-sdk-samples/sample-fullstack-spring/README.md

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ curl -s http://localhost:8081/scim/v2/Users | python3 -c "import sys,json; [prin
7878

7979
## Observability
8080

81-
The sample includes an optional observability stack (Prometheus + Grafana) for monitoring SCIM operations.
81+
The sample includes an optional observability stack for monitoring and tracing SCIM operations.
8282

8383
### Start with observability
8484

@@ -87,8 +87,9 @@ docker compose --profile observability up -d
8787
```
8888

8989
This starts all services plus:
90-
- **Prometheus** at http://localhost:9091 -- scrapes `/actuator/prometheus` from both SCIM servers
91-
- **Grafana** at http://localhost:3000 (login: admin / admin) -- pre-built SCIM dashboard
90+
- **Prometheus** at http://localhost:9091 — scrapes `/actuator/prometheus` from both SCIM servers
91+
- **Grafana** at http://localhost:3000 (login: admin / admin) — pre-built SCIM dashboard
92+
- **Jaeger** at http://localhost:16686 — distributed tracing UI (traces from OpenTelemetry)
9293

9394
### SCIM Dashboard
9495

@@ -176,11 +177,14 @@ cd ../shared-frontend && npm install && npm run dev
176177

177178
## Services
178179

179-
| Service | URL | Description |
180-
|------------------|----------------------------|--------------------------------|
181-
| Frontend | http://localhost:5173 | React UI |
182-
| Primary Backend | http://localhost:8080 | SCIM server + REST API |
183-
| Target Backend | http://localhost:8081 | Outbound provisioning target |
184-
| Keycloak | http://localhost:9090 | Identity provider (admin/admin)|
185-
| PostgreSQL | localhost:5432 | Primary database |
186-
| PostgreSQL Target| localhost:5433 | Target database |
180+
| Service | URL | Description |
181+
|------------------|----------------------------|------------------------------------|
182+
| Frontend | http://localhost:5173 | React UI |
183+
| Primary Backend | http://localhost:8080 | SCIM server + REST API |
184+
| Target Backend | http://localhost:8081 | Outbound provisioning target |
185+
| Keycloak | http://localhost:9090 | Identity provider (admin/admin) |
186+
| PostgreSQL | localhost:5432 | Primary database |
187+
| PostgreSQL Target| localhost:5433 | Target database |
188+
| Prometheus | http://localhost:9091 | Metrics (observability profile) |
189+
| Grafana | http://localhost:3000 | Dashboards (admin/admin) |
190+
| Jaeger | http://localhost:16686 | Distributed tracing UI |

scim2-sdk-samples/sample-fullstack-spring/docker-compose.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ services:
7171
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: http://keycloak:9090/realms/scim-sample/protocol/openid-connect/certs
7272
# Outbound provisioning — push changes to the target SCIM server
7373
SCIM_CLIENT_BASE_URL: http://backend-target:8080/scim/v2
74+
# OpenTelemetry — export traces to Jaeger
75+
OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4317
7476
ports:
7577
- "8080:8080"
7678
depends_on:
@@ -181,6 +183,15 @@ services:
181183
depends_on:
182184
- prometheus
183185

186+
jaeger:
187+
image: jaegertracing/all-in-one:latest
188+
profiles: ["observability"]
189+
ports:
190+
- "16686:16686"
191+
- "4317:4317"
192+
environment:
193+
COLLECTOR_OTLP_ENABLED: true
194+
184195
volumes:
185196
pgdata:
186197
pgdata-target:

scim2-sdk-samples/sample-fullstack-spring/pom.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@
5757
<groupId>io.micrometer</groupId>
5858
<artifactId>micrometer-registry-prometheus</artifactId>
5959
</dependency>
60+
<dependency>
61+
<groupId>io.opentelemetry</groupId>
62+
<artifactId>opentelemetry-api</artifactId>
63+
</dependency>
64+
<dependency>
65+
<groupId>io.opentelemetry</groupId>
66+
<artifactId>opentelemetry-sdk</artifactId>
67+
</dependency>
68+
<dependency>
69+
<groupId>io.opentelemetry</groupId>
70+
<artifactId>opentelemetry-exporter-otlp</artifactId>
71+
</dependency>
6072

6173
<!-- Database -->
6274
<dependency>
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Copyright 2026 Marcos Barbero
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.example.scim.config;
17+
18+
import io.opentelemetry.api.OpenTelemetry;
19+
import io.opentelemetry.api.trace.Tracer;
20+
import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter;
21+
import io.opentelemetry.sdk.OpenTelemetrySdk;
22+
import io.opentelemetry.sdk.resources.Resource;
23+
import io.opentelemetry.sdk.trace.SdkTracerProvider;
24+
import io.opentelemetry.sdk.trace.export.BatchSpanProcessor;
25+
import org.springframework.beans.factory.annotation.Value;
26+
import org.springframework.context.annotation.Bean;
27+
import org.springframework.context.annotation.Configuration;
28+
29+
@Configuration
30+
public class TracingConfig {
31+
32+
@Bean
33+
public OpenTelemetry openTelemetry(
34+
@Value("${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4317}") String otlpEndpoint) {
35+
var exporter = OtlpGrpcSpanExporter.builder()
36+
.setEndpoint(otlpEndpoint)
37+
.build();
38+
39+
var tracerProvider = SdkTracerProvider.builder()
40+
.addSpanProcessor(BatchSpanProcessor.builder(exporter).build())
41+
.setResource(Resource.builder()
42+
.put("service.name", "scim-sample")
43+
.build())
44+
.build();
45+
46+
return OpenTelemetrySdk.builder()
47+
.setTracerProvider(tracerProvider)
48+
.buildAndRegisterGlobal();
49+
}
50+
51+
@Bean
52+
public Tracer tracer(OpenTelemetry openTelemetry) {
53+
return openTelemetry.getTracer("scim-sample");
54+
}
55+
}

scim2-sdk-spring-boot-autoconfigure/pom.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@
9191
<artifactId>micrometer-core</artifactId>
9292
<optional>true</optional>
9393
</dependency>
94+
<dependency>
95+
<groupId>io.opentelemetry</groupId>
96+
<artifactId>opentelemetry-api</artifactId>
97+
<optional>true</optional>
98+
</dependency>
9499
<dependency>
95100
<groupId>org.springframework.security</groupId>
96101
<artifactId>spring-security-oauth2-jose</artifactId>
@@ -144,6 +149,11 @@
144149
<artifactId>kotlin-faker</artifactId>
145150
<scope>test</scope>
146151
</dependency>
152+
<dependency>
153+
<groupId>io.opentelemetry</groupId>
154+
<artifactId>opentelemetry-sdk-testing</artifactId>
155+
<scope>test</scope>
156+
</dependency>
147157
<dependency>
148158
<groupId>io.kotest</groupId>
149159
<artifactId>kotest-assertions-core-jvm</artifactId>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Copyright 2026 Marcos Barbero
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.marcosbarbero.scim2.spring.autoconfigure
17+
18+
import com.marcosbarbero.scim2.core.observability.ScimTracer
19+
import com.marcosbarbero.scim2.spring.observability.OpenTelemetryScimTracer
20+
import io.opentelemetry.api.trace.Tracer
21+
import org.springframework.beans.factory.ObjectProvider
22+
import org.springframework.boot.autoconfigure.AutoConfiguration
23+
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass
24+
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
25+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
26+
import org.springframework.context.annotation.Bean
27+
import org.springframework.context.annotation.Configuration
28+
29+
@AutoConfiguration(before = [ScimServerAutoConfiguration::class])
30+
@ConditionalOnClass(name = ["io.opentelemetry.api.trace.Tracer"])
31+
@ConditionalOnProperty(prefix = "scim.tracing", name = ["enabled"], havingValue = "true", matchIfMissing = true)
32+
class ScimTracerAutoConfiguration {
33+
34+
@Configuration
35+
@ConditionalOnClass(Tracer::class)
36+
class OpenTelemetryTracerConfiguration {
37+
38+
@Bean
39+
@ConditionalOnMissingBean(ScimTracer::class)
40+
fun openTelemetryScimTracer(tracer: ObjectProvider<Tracer>): ScimTracer? = tracer.ifAvailable?.let { OpenTelemetryScimTracer(it) }
41+
}
42+
}

0 commit comments

Comments
 (0)