From b98b6303317f855913d037a710f925f743456bf9 Mon Sep 17 00:00:00 2001 From: foukou19 Date: Mon, 5 Oct 2026 11:26:05 +0200 Subject: [PATCH 01/10] feat(config): update OpenTelemetry OTLP Signed-off-by: foukou19 --- pom.xml | 18 ++++++- .../processor/IngestionProcessor.java | 50 +++++++++++-------- .../processor/SecurityProcessor.java | 31 ++++++++---- src/main/resources/application.yml | 43 +++++++--------- 4 files changed, 85 insertions(+), 57 deletions(-) diff --git a/pom.xml b/pom.xml index 456b4c59..b78fe890 100644 --- a/pom.xml +++ b/pom.xml @@ -273,7 +273,7 @@ + execution --> com.schibsted.spt.data jslt @@ -337,6 +337,21 @@ camel-rest-starter + + org.springframework.boot + spring-boot-starter-opentelemetry + + + + org.apache.camel.springboot + camel-opentelemetry-starter + + + + org.apache.camel.springboot + camel-micrometer-starter + + @@ -545,6 +560,7 @@ org.hibernate.orm:hibernate-core:6.6.29.Final ch.qos.logback:logback-classic:1.5.34 ch.qos.logback:logback-core:1.5.34 + org.codehaus.mojo:animal-sniffer-annotations:1.26 diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/IngestionProcessor.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/IngestionProcessor.java index 0547d335..cbd9a43a 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/IngestionProcessor.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/IngestionProcessor.java @@ -11,6 +11,7 @@ import com.decathlon.idp_core.domain.port.MappingEnginePort; import com.decathlon.idp_core.domain.service.entity.EntityService; +import io.opentelemetry.api.trace.Span; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -36,11 +37,12 @@ public class IngestionProcessor { /// @param webhookConnectorConfiguration the connector with mapping definitions /// @param payload the raw JSON payload from the webhook public void ingest(String payload, WebhookConnector webhookConnectorConfiguration) { - log.info("Starting ingestion for webhook connector: {}", - webhookConnectorConfiguration.identifier()); + String connectorIdentifier = webhookConnectorConfiguration.identifier(); + Span.current().setAttribute("idp.webhook.identifier", connectorIdentifier); + + log.info("Starting ingestion for webhook connector: {}", connectorIdentifier); webhookConnectorConfiguration.mappings().forEach(mapping -> applyMapping(payload, mapping)); - log.info("Completed ingestion for webhook connector: {}", - webhookConnectorConfiguration.identifier()); + log.info("Completed ingestion for webhook connector: {}", connectorIdentifier); } /// Applies a single mapping to the payload and persists the resulting entity. @@ -51,24 +53,30 @@ public void ingest(String payload, WebhookConnector webhookConnectorConfiguratio /// @param mapping the mapping definition to apply private void applyMapping(String payload, EntityDynamicMapping mapping) { log.debug("Applying mapping for template: {} with action: {}", mapping.identifier(), mapping.action()); - Entity entity = mappingEngine.mapToEntity(payload, mapping); - - if (entity == null) { - log.debug("Mapping filter excluded payload for template: {}", - mapping.entityTemplateIdentifier()); - return; + try { + Entity entity = mappingEngine.mapToEntity(payload, mapping); + + if (entity == null) { + Span.current().setAttribute("idp.webhook.mapping.result", "FILTERED"); + log.debug("Mapping filter excluded payload for template: {}", + mapping.entityTemplateIdentifier()); + return; + } + switch (mapping.action()) { + case UPDATE_ENTITY -> handleUpdate(entity); + case UPDATE_PROPERTIES -> handleUpdateProperties(entity); + case UPDATE_RELATIONS -> handleUpdateRelations(entity); + case DELETE_ENTITY -> handleDelete(entity); + case null, default -> log.warn("Unsupported or null mapping action: {}", mapping.action()); + } + + Span.current().setAttribute("idp.webhook.mapping.result", "SUCCESS"); + log.info("Successfully processed action {} for entity: {} under template: {}", + mapping.action(), entity.identifier(), entity.templateIdentifier()); + } catch (RuntimeException exception) { + Span.current().setAttribute("idp.webhook.mapping.result", "ERROR"); + throw exception; } - - switch (mapping.action()) { - case UPDATE_ENTITY -> handleUpdate(entity); - case UPDATE_PROPERTIES -> handleUpdateProperties(entity); - case UPDATE_RELATIONS -> handleUpdateRelations(entity); - case DELETE_ENTITY -> handleDelete(entity); - case null, default -> log.warn("Unsupported or null mapping action: {}", mapping.action()); - } - - log.info("Successfully processed action {} for entity: {} under template: {}", - mapping.action(), entity.identifier(), entity.templateIdentifier()); } /// Handles the Update action for an entity. diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/SecurityProcessor.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/SecurityProcessor.java index bed34cd3..ab31cefd 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/SecurityProcessor.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/SecurityProcessor.java @@ -12,6 +12,7 @@ import com.decathlon.idp_core.infrastructure.adapters.ingestion.exception.WebhookSecurityException; import com.decathlon.idp_core.infrastructure.adapters.ingestion.security.WebhookRequestAuthenticator; +import io.opentelemetry.api.trace.Span; import lombok.extern.slf4j.Slf4j; @Component @@ -28,24 +29,34 @@ public SecurityProcessor(List strategies) { public void validate(Map headers, byte[] rawPayload, WebhookConnector webhookConnector) { + String webhookIdentifier = webhookConnector.identifier(); + Span.current().setAttribute("idp.webhook.identifier", webhookIdentifier); + WebhookSecurity security = webhookConnector.security(); if (security == null || security.type() == WebhookSecurityType.NONE) { + Span.current().setAttribute("idp.webhook.security.result", "SUCCESS"); return; } - WebhookSecurityStrategy strategy = strategies.stream() - .filter(candidate -> candidate.supports(security.type())).findFirst() - .orElseThrow(() -> new WebhookSecurityException(WEBHOOK_AUTHENTICATION_FAILED_MESSAGE)); + try { + WebhookSecurityStrategy strategy = strategies.stream() + .filter(candidate -> candidate.supports(security.type())).findFirst() + .orElseThrow(() -> new WebhookSecurityException(WEBHOOK_AUTHENTICATION_FAILED_MESSAGE)); - if (!(strategy instanceof WebhookRequestAuthenticator authenticator)) { - throw new WebhookSecurityException(WEBHOOK_AUTHENTICATION_FAILED_MESSAGE); - } + if (!(strategy instanceof WebhookRequestAuthenticator authenticator)) { + throw new WebhookSecurityException(WEBHOOK_AUTHENTICATION_FAILED_MESSAGE); + } - authenticator.validateRequest(headers, rawPayload == null ? new byte[0] : rawPayload, - security.config()); + authenticator.validateRequest(headers, rawPayload == null ? new byte[0] : rawPayload, + security.config()); - log.debug("Webhook security validation passed for connector '{}' with strategy '{}'.", - webhookConnector.identifier(), security.type()); + Span.current().setAttribute("idp.webhook.security.result", "SUCCESS"); + log.debug("Webhook security validation passed for connector '{}' with strategy '{}'.", + webhookIdentifier, security.type()); + } catch (RuntimeException exception) { + Span.current().setAttribute("idp.webhook.security.result", "REJECTED"); + throw exception; + } } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bd217e2e..04c1f53c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -180,17 +180,25 @@ logging: management: tracing: # Tracing is disabled by default and only enabled via environment variables (OTEL_TRACING_ENABLED). - enabled: false + enabled: ${OTEL_TRACING_ENABLED:true} sampling: probability: 1.0 # 100% des traces otlp: - tracing: - endpoint: ${OPEN_TELEMETRY_URL:""} - headers: - swagger: - enabled: ${APP_SECURITY_SWAGGER_ENABLED:true} - authorization: ${OPEN_TELEMETRY_TOKEN:""} + traces: + export: + url: ${OTEL_EXPORTER_OTLP_ENDPOINT} + protocol: ${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf} + metrics: + export: + url: ${OTEL_EXPORTER_OTLP_ENDPOINT} + protocol: ${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf} + opentelemetry: + resource-attributes: + deployment.environment: ${OTEL_ENV} + service.namespace: ${OTEL_SERVICE_NAMESPACE} + X-Datadog-Org: ${ORG} + X-Backend: ${BACKEND} endpoints: web: @@ -221,22 +229,7 @@ management: # Avoids leaking internal infrastructure info to unauthenticated callers. show-details: when_authorized + camel: - component: - platform-http: - engine: "servlet" - # Never expose the health "details" map (e.g. database product name/version, - # validation query, disk space paths). Only UP/DOWN status is shown. - show-details: never - # Still surface per-component status (e.g. db: UP/DOWN) so direct - # dependencies can be checked, without leaking their details. - show-components: always - health: - diskspace: - # DiskSpace is not a direct dependency and its free/threshold values - # are not relevant to consumers checking service health. - enabled: false - ssl: - # SSL certificate/chain info is internal infrastructure detail and - # must not be exposed via actuator. - enabled: false + opentelemetry: + enabled: true From 76f3ec9a24a264347a2acd1c07f8513cd1bb4de2 Mon Sep 17 00:00:00 2001 From: foukou19 Date: Wed, 7 Oct 2026 09:09:03 +0200 Subject: [PATCH 02/10] feat(config): Adapt webhooks security configuration Signed-off-by: foukou19 --- docs/src/concepts/webhooks.md | 16 +++- docs/src/deployment/docker.md | 11 +-- .../webhook/WebhookSecurity.java | 5 +- .../WebhookSecurityValidationService.java | 2 +- .../InboundWebhookSecurityContractDtoIn.java | 7 +- .../processor/IngestionProcessor.java | 11 ++- .../processor/SecurityProcessor.java | 11 ++- .../security/JwtBearerSecurityValidator.java | 55 +++++++++++-- src/main/resources/application.yml | 14 ++-- .../WebhookSecurityValidationServiceTest.java | 10 +++ ...nboundWebhookManagementControllerTest.java | 78 ++++++++++++++++++- .../webhook/InboundWebhookMapperTest.java | 26 +++++++ .../JwtBearerSecurityValidatorTest.java | 49 +++++++++++- src/test/resources/application-test.yml | 3 + 14 files changed, 256 insertions(+), 42 deletions(-) diff --git a/docs/src/concepts/webhooks.md b/docs/src/concepts/webhooks.md index d1030c8c..43d8801c 100644 --- a/docs/src/concepts/webhooks.md +++ b/docs/src/concepts/webhooks.md @@ -83,7 +83,7 @@ again at runtime. | `STATIC_TOKEN` | `header_name`, `secret_alias` | Compares a header value with a secret loaded from the environment | | `BASIC_AUTH` | `username`, `secret_alias` | Compares the `Authorization: Basic ...` header with the configured username and secret | | `JWT_BEARER` | `jwks_uri`, `client_id_field`, `client_id_values` | Validates the bearer token against a JWKS endpoint, then checks caller identity claim allow-list | -| `NONE` | none | Skips authentication | +| `NONE` | none (`config` is optional) | Skips authentication | > [!IMPORTANT] > Security configuration keys accept `snake_case` and `camelCase` variants for the supported fields. @@ -144,14 +144,26 @@ value in the connector configuration. } ``` +=== "NONE" + +```json +{ + "type": "NONE" +} +``` + +For `NONE`, you can omit `config`, set it to `null`, or pass `{}`. Any non-empty `config` is rejected. + ### JWT_BEARER Configuration Reference - `jwks_uri` (required): literal HTTPS URL used to validate JWT signatures. -- `client_id_field` (required): claim name used to identify the caller. Allowed values are only `azp` or `email`. +- `client_id_field` (required): name of the JWT claim that identifies the caller. Any non-empty claim name is accepted, for example `client_id`, `sub`, `azp`, `email`, or a custom claim. Existing configurations using `azp` or `email` keep working unchanged. - `client_id_values` (required): comma-separated allow-list of accepted claim values. - `expected_audience` (optional): comma-separated allow-list for the `aud` claim. If present, at least one JWT audience must match. - `allowed-jwks-hosts` (optional, environment-backed): comma-separated allow-list of trusted JWKS hosts under `idp.security.webhook`. Set `ALLOWED_JWKS_HOSTS` when you need to permit a specific issuer host explicitly. +The caller must send the JWT in an `Authorization: Bearer ` header. If the claim named by `client_id_field` is missing from the token, the request is rejected with `401 Unauthorized`. If the claim value is not in `client_id_values`, the request is rejected with `403 Forbidden`. + > [!WARNING] > `jwks_uri` must use a resolvable public HTTPS host. Local hosts, private networks, link-local addresses, and group-address destinations are rejected at connector creation. > If you configure `allowed-jwks-hosts`, the listed hosts bypass this rejection path intentionally. diff --git a/docs/src/deployment/docker.md b/docs/src/deployment/docker.md index e6254a3c..7a76d02f 100644 --- a/docs/src/deployment/docker.md +++ b/docs/src/deployment/docker.md @@ -224,11 +224,12 @@ ENTRYPOINT ["java", "-jar", "app.jar"] ### Observability -| Variable | Description | Default | -| ----------------------------- | -------------- | ---------- | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP endpoint | - | -| `OTEL_SERVICE_NAME` | Service name | `idp-core` | -| `LOG_LEVEL` | Root log level | `INFO` | +| Variable | Description | Default | +| ----------------------------- | --------------------------------------------------- | ---------- | +| `OTEL_TRACING_ENABLED` | Enables OpenTelemetry tracing (`false` disables it) | `true` | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP endpoint | - | +| `OTEL_SERVICE_NAME` | Service name | `idp-core` | +| `LOG_LEVEL` | Root log level | `INFO` | --- diff --git a/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java index 9aba1e97..afbda12c 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java @@ -14,9 +14,10 @@ public record WebhookSecurity(WebhookSecurityType type, Map conf if (type == null) { throw new WebhookSecurityConfigurationException(WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY); } - if (config == null) { + // NONE needs no config: a missing config is normalized to an empty map + if (config == null && type != WebhookSecurityType.NONE) { throw new WebhookSecurityConfigurationException(WEBHOOK_CONNECTOR_SECURITY_CONFIG_MANDATORY); } - config = Map.copyOf(config); + config = config == null ? Map.of() : Map.copyOf(config); } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java index 97f95b33..2da3d4de 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java @@ -48,7 +48,7 @@ public void validateForCreation(WebhookSecurity security) { } private void validateNoSecurityConfig(Map config) { - if (!config.isEmpty()) { + if (config != null && !config.isEmpty()) { throw new WebhookSecurityConfigurationException( "Webhook security config must be empty when type is NONE"); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookSecurityContractDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookSecurityContractDtoIn.java index ee59b552..8d78b6aa 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookSecurityContractDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookSecurityContractDtoIn.java @@ -1,17 +1,18 @@ package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_SECURITY_CONFIG_MANDATORY; import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY; import java.util.Map; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; /// Security contract request payload represented as `{ type, config }`. +/// +/// `config` is optional here: it is only mandatory for strategies other than +/// `NONE`, which the domain enforces. public record InboundWebhookSecurityContractDtoIn( @NotBlank(message = WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY) String type, - @NotNull(message = WEBHOOK_CONNECTOR_SECURITY_CONFIG_MANDATORY) Map config) { + Map config) { public InboundWebhookSecurityContractDtoIn { config = config != null ? Map.copyOf(config) : null; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/IngestionProcessor.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/IngestionProcessor.java index cbd9a43a..6a0c825f 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/IngestionProcessor.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/IngestionProcessor.java @@ -20,6 +20,9 @@ @RequiredArgsConstructor public class IngestionProcessor { + private static final String SPAN_ATTRIBUTE_WEBHOOK_IDENTIFIER = "idp.webhook.identifier"; + private static final String SPAN_ATTRIBUTE_MAPPING_RESULT = "idp.webhook.mapping.result"; + private final MappingEnginePort mappingEngine; private final EntityService entityService; @@ -38,7 +41,7 @@ public class IngestionProcessor { /// @param payload the raw JSON payload from the webhook public void ingest(String payload, WebhookConnector webhookConnectorConfiguration) { String connectorIdentifier = webhookConnectorConfiguration.identifier(); - Span.current().setAttribute("idp.webhook.identifier", connectorIdentifier); + Span.current().setAttribute(SPAN_ATTRIBUTE_WEBHOOK_IDENTIFIER, connectorIdentifier); log.info("Starting ingestion for webhook connector: {}", connectorIdentifier); webhookConnectorConfiguration.mappings().forEach(mapping -> applyMapping(payload, mapping)); @@ -57,7 +60,7 @@ private void applyMapping(String payload, EntityDynamicMapping mapping) { Entity entity = mappingEngine.mapToEntity(payload, mapping); if (entity == null) { - Span.current().setAttribute("idp.webhook.mapping.result", "FILTERED"); + Span.current().setAttribute(SPAN_ATTRIBUTE_MAPPING_RESULT, "FILTERED"); log.debug("Mapping filter excluded payload for template: {}", mapping.entityTemplateIdentifier()); return; @@ -70,11 +73,11 @@ private void applyMapping(String payload, EntityDynamicMapping mapping) { case null, default -> log.warn("Unsupported or null mapping action: {}", mapping.action()); } - Span.current().setAttribute("idp.webhook.mapping.result", "SUCCESS"); + Span.current().setAttribute(SPAN_ATTRIBUTE_MAPPING_RESULT, "SUCCESS"); log.info("Successfully processed action {} for entity: {} under template: {}", mapping.action(), entity.identifier(), entity.templateIdentifier()); } catch (RuntimeException exception) { - Span.current().setAttribute("idp.webhook.mapping.result", "ERROR"); + Span.current().setAttribute(SPAN_ATTRIBUTE_MAPPING_RESULT, "ERROR"); throw exception; } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/SecurityProcessor.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/SecurityProcessor.java index ab31cefd..104f4ea4 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/SecurityProcessor.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/SecurityProcessor.java @@ -20,6 +20,9 @@ public class SecurityProcessor { private static final String WEBHOOK_AUTHENTICATION_FAILED_MESSAGE = "Webhook authentication failed"; + private static final String SPAN_ATTRIBUTE_WEBHOOK_IDENTIFIER = "idp.webhook.identifier"; + private static final String SPAN_ATTRIBUTE_SECURITY_RESULT = "idp.webhook.security.result"; + private static final String RESULT_SUCCESS = "SUCCESS"; private final List strategies; @@ -30,11 +33,11 @@ public SecurityProcessor(List strategies) { public void validate(Map headers, byte[] rawPayload, WebhookConnector webhookConnector) { String webhookIdentifier = webhookConnector.identifier(); - Span.current().setAttribute("idp.webhook.identifier", webhookIdentifier); + Span.current().setAttribute(SPAN_ATTRIBUTE_WEBHOOK_IDENTIFIER, webhookIdentifier); WebhookSecurity security = webhookConnector.security(); if (security == null || security.type() == WebhookSecurityType.NONE) { - Span.current().setAttribute("idp.webhook.security.result", "SUCCESS"); + Span.current().setAttribute(SPAN_ATTRIBUTE_SECURITY_RESULT, RESULT_SUCCESS); return; } @@ -50,11 +53,11 @@ public void validate(Map headers, byte[] rawPayload, authenticator.validateRequest(headers, rawPayload == null ? new byte[0] : rawPayload, security.config()); - Span.current().setAttribute("idp.webhook.security.result", "SUCCESS"); + Span.current().setAttribute(SPAN_ATTRIBUTE_SECURITY_RESULT, RESULT_SUCCESS); log.debug("Webhook security validation passed for connector '{}' with strategy '{}'.", webhookIdentifier, security.type()); } catch (RuntimeException exception) { - Span.current().setAttribute("idp.webhook.security.result", "REJECTED"); + Span.current().setAttribute(SPAN_ATTRIBUTE_SECURITY_RESULT, "REJECTED"); throw exception; } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/JwtBearerSecurityValidator.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/JwtBearerSecurityValidator.java index 466e1d38..2ac0f026 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/JwtBearerSecurityValidator.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/JwtBearerSecurityValidator.java @@ -21,6 +21,9 @@ import com.decathlon.idp_core.infrastructure.adapters.ingestion.exception.WebhookAuthForbiddenException; import com.decathlon.idp_core.infrastructure.adapters.ingestion.exception.WebhookAuthUnauthorizedException; +import lombok.extern.slf4j.Slf4j; + +@Slf4j @Component public class JwtBearerSecurityValidator implements @@ -37,8 +40,10 @@ public class JwtBearerSecurityValidator private static final String KEY_EXPECTED_AUDIENCE_CAMEL_CASE = "expectedAudience"; private static final String BEARER_PREFIX = "Bearer "; - private static final String CLIENT_CLAIM_AZP = "azp"; - private static final String CLIENT_CLAIM_EMAIL = "email"; + private static final String CALLER_RESULT_ACCEPTED = "ACCEPTED"; + private static final String CALLER_RESULT_REJECTED = "REJECTED"; + private static final String CLAIM_EMAIL = "email"; + private static final int MAX_LOGGED_CALLER_LENGTH = 128; private final WebhookJwtDecoderProvider jwtDecoderProvider; private final Set allowedJwksHosts; @@ -81,9 +86,9 @@ public void validateConfiguration(Map config) { "Invalid client_id_field for JWT_BEARER security: runtime environment references are not supported"); } - if (!CLIENT_CLAIM_AZP.equals(clientIdField) && !CLIENT_CLAIM_EMAIL.equals(clientIdField)) { + if (!StringUtils.hasText(clientIdField)) { throw new WebhookSecurityConfigurationException( - "Invalid client_id_field for JWT_BEARER security: must be 'azp' (Client ID) or 'email' (Service Account)"); + "Invalid client_id_field for JWT_BEARER security: must be a non-empty claim name"); } String clientIdValues = WebhookSecurityConfigurationUtils.required(config, @@ -137,18 +142,52 @@ public void validateRequest(Map headers, byte[] rawPayload, String actualCallerIdentity = jwt.getClaimAsString(clientIdField); if (!StringUtils.hasText(actualCallerIdentity)) { - throw new WebhookAuthForbiddenException("JWT missing required claim: " + clientIdField); + logCallerOutcome(clientIdField, null, CALLER_RESULT_REJECTED); + throw new WebhookAuthUnauthorizedException("JWT missing required claim: " + clientIdField); } Set allowedIdentities = parseAllowedClientIdValues(clientIdValues); if (!allowedIdentities.contains(actualCallerIdentity)) { + logCallerOutcome(clientIdField, actualCallerIdentity, CALLER_RESULT_REJECTED); throw new WebhookAuthForbiddenException( "JWT client or service account identifier was rejected"); } if (StringUtils.hasText(optionalExpectedAudience)) { - validateAudienceClaim(jwt, optionalExpectedAudience); + try { + validateAudienceClaim(jwt, optionalExpectedAudience); + } catch (WebhookAuthForbiddenException exception) { + logCallerOutcome(clientIdField, actualCallerIdentity, CALLER_RESULT_REJECTED); + throw exception; + } + } + + logCallerOutcome(clientIdField, actualCallerIdentity, CALLER_RESULT_ACCEPTED); + } + + /// Logs the caller identity and the validation outcome. The token itself is + /// never logged. The caller value is omitted when the identifying claim is + /// `email`, because an email address is personal data. + private void logCallerOutcome(String claimName, String caller, String result) { + String loggedCaller = resolveLoggedCaller(claimName, caller); + if (CALLER_RESULT_ACCEPTED.equals(result)) { + log.info("webhook_jwt_caller claim={} caller={} result={}", claimName, loggedCaller, result); + } else { + log.warn("webhook_jwt_caller claim={} caller={} result={}", claimName, loggedCaller, result); + } + } + + private String resolveLoggedCaller(String claimName, String caller) { + if (caller == null) { + return ""; + } + if (CLAIM_EMAIL.equalsIgnoreCase(claimName)) { + return ""; } + String sanitized = caller.replaceAll("\\p{Cntrl}", "_"); + return sanitized.length() > MAX_LOGGED_CALLER_LENGTH + ? sanitized.substring(0, MAX_LOGGED_CALLER_LENGTH) + : sanitized; } /// Validates the jwks_uri to prevent SSRF attacks. @@ -220,8 +259,8 @@ private String resolveRequiredClientIdField(Map config) { } catch (WebhookSecurityConfigurationException _) { throw new WebhookSecurityConfigurationException( "Missing required JWT_BEARER config key. Expected one of: client_id_field, clientIdField. " - + "Allowed values: 'azp' (Client ID) or 'email' (Service Account). " - + "Example: \"client_id_field\": \"email\""); + + "Value must be a non-empty JWT claim name (for example 'client_id', 'azp', 'email'). " + + "Example: \"client_id_field\": \"client_id\""); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 04c1f53c..bc41a2d1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -179,7 +179,6 @@ logging: management: tracing: - # Tracing is disabled by default and only enabled via environment variables (OTEL_TRACING_ENABLED). enabled: ${OTEL_TRACING_ENABLED:true} sampling: probability: 1.0 # 100% des traces @@ -187,18 +186,19 @@ management: otlp: traces: export: - url: ${OTEL_EXPORTER_OTLP_ENDPOINT} + url: ${OTEL_EXPORTER_OTLP_ENDPOINT:} protocol: ${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf} metrics: export: - url: ${OTEL_EXPORTER_OTLP_ENDPOINT} + url: ${OTEL_EXPORTER_OTLP_ENDPOINT:} protocol: ${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf} opentelemetry: resource-attributes: - deployment.environment: ${OTEL_ENV} - service.namespace: ${OTEL_SERVICE_NAMESPACE} - X-Datadog-Org: ${ORG} - X-Backend: ${BACKEND} + deployment.environment: ${OTEL_ENV:local} + service.namespace: ${OTEL_SERVICE_NAMESPACE:idp-core} + service.name: ${OTEL_SERVICE_NAME:idp-core} + X-Datadog-Org: ${ORG:} + X-Backend: ${BACKEND:} endpoints: web: diff --git a/src/test/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationServiceTest.java index c916884b..69611574 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationServiceTest.java @@ -2,6 +2,7 @@ import static com.decathlon.idp_core.domain.model.enums.WebhookSecurityType.HMAC_SHA256; import static com.decathlon.idp_core.domain.model.enums.WebhookSecurityType.JWT_BEARER; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.lenient; @@ -96,6 +97,15 @@ void shouldPassForNoneTypeWithEmptyConfig() { assertThatCode(() -> service.validateForCreation(security)).doesNotThrowAnyException(); } + @Test + @DisplayName("Should normalize a null config of a NONE type to an empty map and pass") + void shouldNormalizeNullConfigForNoneType() { + WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.NONE, null); + + assertThat(security.config()).isNotNull().isEmpty(); + assertThatCode(() -> service.validateForCreation(security)).doesNotThrowAnyException(); + } + @Test @DisplayName("Should throw for NONE type with non-empty config") void shouldThrowForNoneTypeWithNonEmptyConfig() { diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java index d2e62838..df8a9e06 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java @@ -602,6 +602,28 @@ void putWebhook_200() throws Exception { assertWebhookTemplateMapping("connector-put-200"); } + @Test + @WithMockUser + @DisplayName("Should update to NONE security when config is omitted") + void putWebhook_200_none_security_without_config() throws Exception { + createWebhookConnector("connector-put-none-no-config", "Connector Put None Initial"); + + var payload = """ + { + "name": "Connector Put None Updated", + "description": "updated without security config", + "enabled": false, + "mapping_identifiers": ["connector-put-none-no-config-mapping"], + "security": { "type": "NONE" } + } + """; + mockMvc + .perform(MockMvcRequestBuilders.put(WEBHOOK_PATH + "/connector-put-none-no-config") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isOk()).andExpect(jsonPath("$.security.type").value("NONE")) + .andExpect(jsonPath("$.security.config").isEmpty()); + } + @Test @WithMockUser @DisplayName("Should return 404 when connector does not exist") @@ -1028,19 +1050,71 @@ void security_none_empty_config_201() throws Exception { @WithMockUser @DisplayName("NONE — Should create connector without config field") void security_none_no_config_201() throws Exception { + // The "config" key is truly omitted from the request body. var payload = """ { "identifier": "sec-none-no-config", "name": "None No Config", "enabled": false, "mapping_identifiers": [], - "security": { "type": "NONE", "config":{} } + "security": { "type": "NONE" } } """; mockMvc .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) .accept(APPLICATION_JSON).with(csrf()).content(payload)) - .andExpect(status().isCreated()).andExpect(jsonPath("$.security.type").value("NONE")); + .andExpect(status().isCreated()).andExpect(jsonPath("$.security.type").value("NONE")) + .andExpect(jsonPath("$.security.config").isEmpty()); + } + + @Test + @WithMockUser + @DisplayName("NONE — Should create connector with a null config") + void security_none_null_config_201() throws Exception { + var payload = """ + { + "identifier": "sec-none-null-config", + "name": "None Null Config", + "enabled": false, + "mapping_identifiers": [], + "security": { "type": "NONE", "config": null } + } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isCreated()).andExpect(jsonPath("$.security.type").value("NONE")) + .andExpect(jsonPath("$.security.config").isEmpty()); + } + + @Test + @WithMockUser + @DisplayName("NONE — Should return 400 when config is not empty") + void security_none_non_empty_config_400() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(buildSecurityPayload( + "sec-none-with-config", "None With Config", "NONE", "{ \"a\": \"b\" }"))) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser + @DisplayName("HMAC_SHA256 — Should return 400 when config is omitted") + void security_hmac_no_config_400() throws Exception { + var payload = """ + { + "identifier": "sec-hmac-no-config", + "name": "HMAC No Config", + "enabled": false, + "mapping_identifiers": [], + "security": { "type": "HMAC_SHA256" } + } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()); } @Test diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapperTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapperTest.java index 843cafc1..4e56a120 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapperTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapperTest.java @@ -96,6 +96,32 @@ void shouldMapNoneSecurityTypeExplicitly() { assertThat(domain.security().config()).isEmpty(); } + @Test + @DisplayName("Should normalize an omitted (null) config of a NONE security to an empty map") + void shouldNormalizeNullConfigForNoneSecurity() { + InboundWebhookCreateDtoIn request = new InboundWebhookCreateDtoIn("my-connector", "No Auth", + "Webhook without authentication", true, List.of("deployment-mapping"), + new InboundWebhookSecurityContractDtoIn("NONE", null)); + + WebhookConnector domain = mapper.toDomain(request, List.of(resolvedMapping())); + + assertThat(domain.security().type()).isEqualTo(WebhookSecurityType.NONE); + assertThat(domain.security().config()).isNotNull().isEmpty(); + } + + @Test + @DisplayName("Should normalize an omitted (null) config on update for a NONE security") + void shouldNormalizeNullConfigOnUpdateForNoneSecurity() { + InboundWebhookUpdateDtoIn request = new InboundWebhookUpdateDtoIn("No Auth", + "Webhook without authentication", true, List.of("deployment-mapping"), + new InboundWebhookSecurityContractDtoIn("NONE", null)); + + WebhookConnector domain = mapper.toDomainForUpdate("identifier_from_path", request, + List.of(resolvedMapping())); + + assertThat(domain.security().config()).isNotNull().isEmpty(); + } + @Test @DisplayName("Should default to NONE when security section is missing") void shouldDefaultToNoneWhenSecurityIsMissing() { diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/JwtBearerSecurityValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/JwtBearerSecurityValidatorTest.java index a6da25bd..192ce240 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/JwtBearerSecurityValidatorTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/JwtBearerSecurityValidatorTest.java @@ -225,15 +225,29 @@ void shouldThrowWhenClientIdValuesIsEnvironmentReference() { } @Test - @DisplayName("Should throw when client_id_field is neither azp nor email") - void shouldThrowWhenClientIdFieldIsUnsupported() { + @DisplayName("Should accept any non-empty claim name as client_id_field") + void shouldAcceptAnyNonEmptyClientIdField() { + JwtBearerSecurityValidator safeValidator = validatorWithPublicHostResolution(); + + for (String field : List.of("client_id", "sub", "azp", "email", "custom_tenant_id")) { + Map config = Map.of("jwks_uri", "https://issuer/.well-known/jwks.json", + "client_id_field", field, "client_id_values", "expected@example.com"); + + assertThatCode(() -> safeValidator.validateConfiguration(config)) + .doesNotThrowAnyException(); + } + } + + @Test + @DisplayName("Should throw when client_id_field is blank") + void shouldThrowWhenClientIdFieldIsBlank() { JwtBearerSecurityValidator safeValidator = validatorWithPublicHostResolution(); Map config = Map.of("jwks_uri", "https://issuer/.well-known/jwks.json", - "client_id_field", "sub", "client_id_values", "expected@example.com"); + "client_id_field", " ", "client_id_values", "expected@example.com"); assertThatThrownBy(() -> safeValidator.validateConfiguration(config)).isInstanceOf( com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException.class) - .hasMessageContaining("azp").hasMessageContaining("email"); + .hasMessageContaining("client_id_field"); } @Test @@ -394,6 +408,33 @@ void shouldUseAzpClaimWhenConfigured() { .doesNotThrowAnyException(); } + @Test + @DisplayName("Should accept custom client_id claim when configured") + void shouldUseCustomClientIdClaim() { + when(jwtDecoderProvider.get("https://issuer/.well-known/jwks.json")).thenReturn(jwtDecoder); + Map config = Map.of("jwks_uri", "https://issuer/.well-known/jwks.json", + "client_id_field", "client_id", "client_id_values", "app_abc123"); + Map headers = Map.of("Authorization", "Bearer signed-token"); + when(jwtDecoder.decode("signed-token")).thenReturn(jwtWithClaim("client_id", "app_abc123")); + + assertThatCode(() -> validator.validateRequest(headers, new byte[0], config)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("Should return unauthorized when configured claim is missing in JWT") + void shouldReturnUnauthorizedWhenConfiguredClaimMissing() { + when(jwtDecoderProvider.get("https://issuer/.well-known/jwks.json")).thenReturn(jwtDecoder); + Map config = Map.of("jwks_uri", "https://issuer/.well-known/jwks.json", + "client_id_field", "custom_tenant_id", "client_id_values", "tenant"); + Map headers = Map.of("Authorization", "Bearer signed-token"); + when(jwtDecoder.decode("signed-token")).thenReturn(jwtWithClaim("email", "a@b.c")); + + assertThatThrownBy(() -> validator.validateRequest(headers, new byte[0], config)) + .isInstanceOf(WebhookAuthUnauthorizedException.class) + .hasMessageContaining("custom_tenant_id"); + } + @Test @DisplayName("Should reject request when client_id_field is missing at runtime") void shouldRejectWhenClientIdFieldMissingAtRuntime() { diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 3ffe9025..22564216 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -61,6 +61,9 @@ app: jwks-env-key: JWKS_URI jwks-env-value: https://auth.example.com/.well-known/jwks.json management: + # Tests must not create or export traces. + tracing: + enabled: false endpoint: health: probes: From e46ae700d15439fea9821157de4ac4e71e69e136 Mon Sep 17 00:00:00 2001 From: foukou19 Date: Thu, 8 Oct 2026 08:58:00 +0200 Subject: [PATCH 03/10] feat(config): update webhook.md Signed-off-by: foukou19 --- docs/src/features/webhooks.md | 30 +++++++++++++++--------------- src/main/resources/application.yml | 3 ++- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/docs/src/features/webhooks.md b/docs/src/features/webhooks.md index 037cd707..e1422485 100644 --- a/docs/src/features/webhooks.md +++ b/docs/src/features/webhooks.md @@ -80,16 +80,16 @@ This validation keeps the connector configuration aligned with the current data Each connector declares one security type. IDP-Core validates the configuration at creation time and validates requests again at runtime. -| Type | Required configuration keys | Runtime behavior | -|----------------|---------------------------------------------------|--------------------------------------------------------------------------------------------------| -| `HMAC_SHA256` | `header_name`, `secret_alias`, `prefix` | Computes the SHA-256 HMAC of the raw body and compares it with the request header | -| `STATIC_TOKEN` | `header_name`, `secret_alias` | Compares a header value with a secret loaded from the environment | -| `BASIC_AUTH` | `username`, `secret_alias` | Compares the `Authorization: Basic ...` header with the configured username and secret | -| `JWT_BEARER` | `jwks_uri`, `client_id_field`, `client_id_values` | Validates the bearer token against a JWKS endpoint, then checks caller identity claim allow-list | -| `NONE` | none (`config` is optional) | Skips authentication | +| Type | Required configuration keys | Runtime behavior | +|------------------|-----------------------------------------------------|--------------------------------------------------------------------------------------------------| +| `HMAC_SHA256` | `header_name`, `secret_alias`, `prefix` | Computes the SHA-256 HMAC of the raw body and compares it with the request header | +| `STATIC_TOKEN` | `header_name`, `secret_alias` | Compares a header value with a secret loaded from the environment | +| `BASIC_AUTH` | `username`, `secret_alias` | Compares the `Authorization: Basic ...` header with the configured username and secret | +| `JWT_BEARER` | `jwks_uri`, `client_id_field`, `client_id_values` | Validates the bearer token against a JWKS endpoint, then checks caller identity claim allow-list | +| `NONE` | none (`config` is optional) | Skips authentication | > [!IMPORTANT] -> Security configuration keys accept `snake_case` and `camelCase` variants for the supported fields. +> Configuration keys accept `snake_case` and `camelCase` variants where supported. HMAC prefix uses the `prefix` key and has a default set to `sha256=`. > [!WARNING] > `secret_alias` must reference an environment variable alias in `UPPER_SNAKE_CASE`. It does not store the raw secret value in the connector configuration. @@ -149,13 +149,13 @@ value in the connector configuration. === "NONE" -```json -{ - "type": "NONE" -} -``` + ```json + { + "type": "NONE" + } + ``` -For `NONE`, you can omit `config`, set it to `null`, or pass `{}`. Any non-empty `config` is rejected. + For `NONE`, you can omit `config`, set it to `null`, or pass `{}`. Any non-empty `config` is rejected. ### JWT_BEARER Configuration Reference @@ -215,7 +215,7 @@ Use webhooks when an external system can push JSON events over HTTP and you want - Ingest updates without redeploying IDP-Core - Reuse one generic endpoint for multiple providers -- Apply connector-specific authentication rulesadd +- Apply connector-specific authentication rules - Map external payloads to your own Entity Templates at runtime --- diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bc41a2d1..1391a39b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -179,7 +179,7 @@ logging: management: tracing: - enabled: ${OTEL_TRACING_ENABLED:true} + enabled: ${OTEL_TRACING_ENABLED:false} sampling: probability: 1.0 # 100% des traces @@ -192,6 +192,7 @@ management: export: url: ${OTEL_EXPORTER_OTLP_ENDPOINT:} protocol: ${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf} + enabled: ${OTEL_METRICS_EXPORT_ENABLED:false} opentelemetry: resource-attributes: deployment.environment: ${OTEL_ENV:local} From bb69f19e54e3ad746e5dfe717fdd5841389ae7ce Mon Sep 17 00:00:00 2001 From: foukou19 Date: Fri, 9 Oct 2026 09:22:30 +0200 Subject: [PATCH 04/10] feat(config): update application.yaml Signed-off-by: foukou19 --- .../processor/IngestionProcessor.java | 40 ++++++++----------- src/main/resources/application.yml | 16 +++++--- 2 files changed, 26 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/IngestionProcessor.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/IngestionProcessor.java index f288e569..7a88233f 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/IngestionProcessor.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/IngestionProcessor.java @@ -22,7 +22,6 @@ public class IngestionProcessor { private static final String SPAN_ATTRIBUTE_WEBHOOK_IDENTIFIER = "idp.webhook.identifier"; - private static final String SPAN_ATTRIBUTE_MAPPING_RESULT = "idp.webhook.mapping.result"; private final MappingEnginePort mappingEngine; private final EntityService entityService; @@ -57,30 +56,23 @@ public void ingest(String payload, WebhookConnector webhookConnectorConfiguratio /// @param mapping the mapping definition to apply private void applyMapping(String payload, EntityDynamicMapping mapping) { log.debug("Applying mapping for template: {} with action: {}", mapping.identifier(), mapping.action()); - try { - Entity entity = mappingEngine.mapToEntity(payload, mapping); - - if (entity == null) { - Span.current().setAttribute(SPAN_ATTRIBUTE_MAPPING_RESULT, "FILTERED"); - log.debug("Mapping filter excluded payload for template: {}", - mapping.entityTemplateIdentifier()); - return; - } - switch (mapping.action()) { - case UPDATE_ENTITY -> handleUpdate(entity); - case UPDATE_PROPERTIES -> handleUpdateProperties(entity); - case UPDATE_RELATIONS -> handleUpdateRelations(entity); - case DELETE_ENTITY -> handleDelete(entity); - case null, default -> log.warn("Unsupported or null mapping action: {}", mapping.action()); - } - - Span.current().setAttribute(SPAN_ATTRIBUTE_MAPPING_RESULT, "SUCCESS"); - log.info("Successfully processed action {} for entity: {} under template: {}", - mapping.action(), entity.identifier(), entity.templateIdentifier()); - } catch (RuntimeException exception) { - Span.current().setAttribute(SPAN_ATTRIBUTE_MAPPING_RESULT, "ERROR"); - throw exception; + Entity entity = mappingEngine.mapToEntity(payload, mapping); + + if (entity == null) { + log.debug("Mapping filter excluded payload for template: {}", + mapping.entityTemplateIdentifier()); + return; + } + switch (mapping.action()) { + case UPDATE_ENTITY -> handleUpdate(entity); + case UPDATE_PROPERTIES -> handleUpdateProperties(entity); + case UPDATE_RELATIONS -> handleUpdateRelations(entity); + case DELETE_ENTITY -> handleDelete(entity); + case null, default -> log.warn("Unsupported or null mapping action: {}", mapping.action()); } + + log.info("Successfully processed action {} for entity: {} under template: {}", + mapping.action(), entity.identifier(), entity.templateIdentifier()); } /// Handles the Update action for an entity. diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1391a39b..4d47ca1b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -179,21 +179,25 @@ logging: management: tracing: - enabled: ${OTEL_TRACING_ENABLED:false} sampling: probability: 1.0 # 100% des traces + export: + enabled: ${OTEL_TRACING_ENABLED:false} + # Trace OTLP export lives under management.opentelemetry.tracing.export.otlp.* (see below). otlp: - traces: - export: - url: ${OTEL_EXPORTER_OTLP_ENDPOINT:} - protocol: ${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf} metrics: export: url: ${OTEL_EXPORTER_OTLP_ENDPOINT:} - protocol: ${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf} + transport: ${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf} enabled: ${OTEL_METRICS_EXPORT_ENABLED:false} opentelemetry: + tracing: + export: + otlp: + # Boot does not append /v1/traces itself: the full traces URL is required for HTTP. + endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/traces} + transport: ${OTEL_EXPORTER_OTLP_TRACES_TRANSPORT:http} resource-attributes: deployment.environment: ${OTEL_ENV:local} service.namespace: ${OTEL_SERVICE_NAMESPACE:idp-core} From 6a1d9618ab66e340b31afd247f9e69658d1fd4b3 Mon Sep 17 00:00:00 2001 From: ferial OUKOUKAS <75682459+foukou19@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:01:37 +0200 Subject: [PATCH 05/10] Update docs/src/features/webhooks.md Co-authored-by: Eve Bernhard <115796475+evebrnd@users.noreply.github.com> Signed-off-by: ferial OUKOUKAS <75682459+foukou19@users.noreply.github.com> --- docs/src/features/webhooks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/features/webhooks.md b/docs/src/features/webhooks.md index e1422485..0bb8b2f4 100644 --- a/docs/src/features/webhooks.md +++ b/docs/src/features/webhooks.md @@ -160,7 +160,7 @@ value in the connector configuration. ### JWT_BEARER Configuration Reference - `jwks_uri` (required): literal HTTPS URL used to validate JWT signatures. -- `client_id_field` (required): name of the JWT claim that identifies the caller. Any non-empty claim name is accepted, for example `client_id`, `sub`, `azp`, `email`, or a custom claim. Existing configurations using `azp` or `email` keep working unchanged. +- `client_id_field` (required): name of the JWT claim that identifies the caller. Any non-empty claim name is accepted, for example `client_id`, `sub`, `azp`, `email`, or a custom claim. - `client_id_values` (required): comma-separated allow-list of accepted claim values. - `expected_audience` (optional): comma-separated allow-list for the `aud` claim. If present, at least one JWT audience must match. - `allowed-jwks-hosts` (optional, environment-backed): comma-separated allow-list of trusted JWKS hosts under `idp.security.webhook`. Set `ALLOWED_JWKS_HOSTS` when you need to permit a specific issuer host explicitly. From fee911115e58586ab122762a8783da67ce326966 Mon Sep 17 00:00:00 2001 From: foukou19 Date: Tue, 13 Oct 2026 11:10:31 +0200 Subject: [PATCH 06/10] feat(config): update application.yaml Signed-off-by: foukou19 --- src/main/resources/application.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4d47ca1b..1feaeab4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -180,7 +180,8 @@ logging: management: tracing: sampling: - probability: 1.0 # 100% des traces + # Fraction of traces kept (1.0 = 100%). Standard OTel variable. + probability: ${OTEL_TRACES_SAMPLER_ARG:0.1} export: enabled: ${OTEL_TRACING_ENABLED:false} @@ -188,22 +189,23 @@ management: otlp: metrics: export: - url: ${OTEL_EXPORTER_OTLP_ENDPOINT:} - transport: ${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf} + # Metrics export is opt-in. Signal-specific variables win over the generic ones. + url: ${OTEL_EXPORTER_OTLP_METRICS_ENDPOINT:${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/metrics} enabled: ${OTEL_METRICS_EXPORT_ENABLED:false} opentelemetry: tracing: export: otlp: # Boot does not append /v1/traces itself: the full traces URL is required for HTTP. - endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/traces} + endpoint: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/traces} + # Boot's transport enum only accepts http or grpc (not the OTel value http/protobuf). transport: ${OTEL_EXPORTER_OTLP_TRACES_TRANSPORT:http} + # Extra attributes (for example X-Datadog-Org, X-Backend) are set by the user + # through OTEL_RESOURCE_ATTRIBUTES, not hard-coded here. resource-attributes: deployment.environment: ${OTEL_ENV:local} service.namespace: ${OTEL_SERVICE_NAMESPACE:idp-core} - service.name: ${OTEL_SERVICE_NAME:idp-core} - X-Datadog-Org: ${ORG:} - X-Backend: ${BACKEND:} + service.name: ${OTEL_SERVICE_NAME:internal-developer-platform} endpoints: web: From 92d43bac63b4e4939481e15576094e57e6c32da3 Mon Sep 17 00:00:00 2001 From: foukou19 Date: Tue, 13 Oct 2026 13:21:26 +0200 Subject: [PATCH 07/10] feat(config): update application.yaml Signed-off-by: foukou19 --- pom.xml | 2 +- src/main/resources/application.yml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index b78fe890..50dff004 100644 --- a/pom.xml +++ b/pom.xml @@ -344,7 +344,7 @@ org.apache.camel.springboot - camel-opentelemetry-starter + camel-opentelemetry2-starter diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1feaeab4..020691b0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -192,6 +192,7 @@ management: # Metrics export is opt-in. Signal-specific variables win over the generic ones. url: ${OTEL_EXPORTER_OTLP_METRICS_ENDPOINT:${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/metrics} enabled: ${OTEL_METRICS_EXPORT_ENABLED:false} + transport: ${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf} opentelemetry: tracing: export: @@ -238,5 +239,5 @@ management: camel: - opentelemetry: + opentelemetry2: enabled: true From cc3b018bad45ec23224ad2cec3e39040268e420c Mon Sep 17 00:00:00 2001 From: foukou19 Date: Tue, 13 Oct 2026 16:09:50 +0200 Subject: [PATCH 08/10] feat(observability): update application.yaml Signed-off-by: foukou19 --- docs/src/deployment/docker.md | 100 ++++++++++++----------------- src/main/resources/application.yml | 4 +- 2 files changed, 43 insertions(+), 61 deletions(-) diff --git a/docs/src/deployment/docker.md b/docs/src/deployment/docker.md index 7a76d02f..85bcc183 100644 --- a/docs/src/deployment/docker.md +++ b/docs/src/deployment/docker.md @@ -224,78 +224,60 @@ ENTRYPOINT ["java", "-jar", "app.jar"] ### Observability -| Variable | Description | Default | -| ----------------------------- | --------------------------------------------------- | ---------- | -| `OTEL_TRACING_ENABLED` | Enables OpenTelemetry tracing (`false` disables it) | `true` | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP endpoint | - | -| `OTEL_SERVICE_NAME` | Service name | `idp-core` | -| `LOG_LEVEL` | Root log level | `INFO` | +IDP-Core follows the standard OpenTelemetry environment variables. Tracing and metrics export are opt-in, and the application keeps the usual OpenTelemetry fallbacks so you can set the standard variables without hard-coding a vendor-specific configuration. ---- - -## Docker Compose Commands - -### Start Services - -```bash -# Start all -docker compose up -d - -# Start specific service -docker compose up -d idp-core - -# View logs -docker compose logs -f idp-core -``` - -### Stop Services - -```bash -# Stop all -docker compose down +The effective precedence is: -# Stop and remove volumes -docker compose down -v -``` - -### Update - -```bash -# Pull latest images -docker compose pull - -# Recreate containers -docker compose up -d --force-recreate -``` - -### Scale +- `OTEL_EXPORTER_OTLP_TRACES_TRANSPORT` > `OTEL_EXPORTER_OTLP_PROTOCOL` > `http` +- `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL` > `OTEL_EXPORTER_OTLP_PROTOCOL` > `http/protobuf` -```bash -# Run 3 instances -docker compose up -d --scale idp-core=3 -``` +This means the application can work in local Docker, in Kubernetes, or in a managed platform while still honoring the standard OTel variables set by the operator or deployment pipeline. ---- +| Variable | Description | Default | +|---------------------------------------|----------------------------------------------------------|------------------------------------------------| +| `OTEL_TRACING_ENABLED` | Enables OpenTelemetry tracing (opt-in) | `false` | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | Base OTLP endpoint for all signals | `http://localhost:4318` | +| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | Full traces URL. It wins over the base endpoint | `/v1/traces` | +| `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | Full metrics URL. It wins over the base endpoint | `/v1/metrics` | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | OTLP protocol used as the generic fallback | `http/protobuf` for metrics, `http` for traces | +| `OTEL_EXPORTER_OTLP_TRACES_TRANSPORT` | Transport for traces. It wins over the generic protocol | `OTEL_EXPORTER_OTLP_PROTOCOL` or `http` | +| `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL` | Transport for metrics. It wins over the generic protocol | `OTEL_EXPORTER_OTLP_PROTOCOL` | +| `OTEL_METRICS_EXPORT_ENABLED` | Enables OTLP metrics export (opt-in) | `false` | +| `OTEL_TRACES_SAMPLER_ARG` | Fraction of traces kept, from `0.0` to `1.0` | `0.1` | +| `OTEL_SERVICE_NAME` | Service name | `internal-developer-platform` | +| `OTEL_SERVICE_NAMESPACE` | Service namespace | `idp-core` | +| `OTEL_ENV` | Value of the `deployment.environment` attribute | `local` | +| `OTEL_RESOURCE_ATTRIBUTES` | Extra resource attributes (`key=value,key=value`) | - | +| `LOG_LEVEL` | Root log level | `INFO` | -## Database Management +The signal-specific variable wins over the generic one, so the image works in the common deployments: -### Initialize Database +- **Docker Compose or a simple container:** set `OTEL_EXPORTER_OTLP_ENDPOINT` +- **Kubernetes with the OpenTelemetry Operator:** the platform injects `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` +- **Datadog or another OTLP collector:** set the standard OTel endpoint and resource attributes +- **Local development:** leave the variables unset and use the built-in `localhost:4318` fallback -The database schema is managed by Flyway and applies automatically on startup. +> [!NOTE] +> Lower `OTEL_TRACES_SAMPLER_ARG` in high-throughput production. Capturing 100% of the traces increases CPU overhead +> and vendor costs. For example, `0.1` keeps 10% of the traces. -### Backup +#### Example: Docker deployment with Datadog -```bash -docker exec postgres pg_dump -U idp idp > backup.sql -``` - -### Restore +This example sends traces and metrics to a Datadog Agent exposing the OTLP receiver on port `4318`. ```bash -cat backup.sql | docker exec -i postgres psql -U idp idp +docker run -d --name idp-core \ + -e OTEL_TRACING_ENABLED=true \ + -e OTEL_METRICS_EXPORT_ENABLED=true \ + -e OTEL_EXPORTER_OTLP_ENDPOINT=http://datadog-agent:4318 \ + -e OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \ + -e OTEL_TRACES_SAMPLER_ARG=0.2 \ + -e OTEL_SERVICE_NAME=idp-core \ + -e OTEL_RESOURCE_ATTRIBUTES="deployment.environment=prod,service.namespace=idp-core,service.name=internal-developer-platform,service.version=1.0.0,team=platform" \ + idp-core:latest ``` ---- +`OTEL_RESOURCE_ATTRIBUTES` is the right place to pass Datadog tags such as `env`, `service`, and `version`. The application does not need hard-coded Datadog values; it just emits the standard OTel resource metadata. ## Troubleshooting diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2ff2a833..83ac46c2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -193,7 +193,7 @@ management: # Metrics export is opt-in. Signal-specific variables win over the generic ones. url: ${OTEL_EXPORTER_OTLP_METRICS_ENDPOINT:${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/metrics} enabled: ${OTEL_METRICS_EXPORT_ENABLED:false} - transport: ${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf} + transport: ${OTEL_EXPORTER_OTLP_METRICS_PROTOCOL:${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf}} opentelemetry: tracing: export: @@ -201,7 +201,7 @@ management: # Boot does not append /v1/traces itself: the full traces URL is required for HTTP. endpoint: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/traces} # Boot's transport enum only accepts http or grpc (not the OTel value http/protobuf). - transport: ${OTEL_EXPORTER_OTLP_TRACES_TRANSPORT:http} + transport: ${OTEL_EXPORTER_OTLP_TRACES_TRANSPORT:${OTEL_EXPORTER_OTLP_PROTOCOL:http}} # Extra attributes (for example X-Datadog-Org, X-Backend) are set by the user # through OTEL_RESOURCE_ATTRIBUTES, not hard-coded here. resource-attributes: From 617e3fd54d720653caffee4a375f848831acb815 Mon Sep 17 00:00:00 2001 From: foukou19 Date: Wed, 14 Oct 2026 10:48:10 +0200 Subject: [PATCH 09/10] feat(observability): update application.yaml Signed-off-by: foukou19 --- docs/src/deployment/docker.md | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/docs/src/deployment/docker.md b/docs/src/deployment/docker.md index 85bcc183..64e5b69b 100644 --- a/docs/src/deployment/docker.md +++ b/docs/src/deployment/docker.md @@ -231,7 +231,7 @@ The effective precedence is: - `OTEL_EXPORTER_OTLP_TRACES_TRANSPORT` > `OTEL_EXPORTER_OTLP_PROTOCOL` > `http` - `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL` > `OTEL_EXPORTER_OTLP_PROTOCOL` > `http/protobuf` -This means the application can work in local Docker, in Kubernetes, or in a managed platform while still honoring the standard OTel variables set by the operator or deployment pipeline. +This means the application can work in local Docker, in Kubernetes, or in a managed platform while still honoring the standard OpenTelemetry variables set by the operator or deployment pipeline. | Variable | Description | Default | |---------------------------------------|----------------------------------------------------------|------------------------------------------------| @@ -254,30 +254,13 @@ The signal-specific variable wins over the generic one, so the image works in th - **Docker Compose or a simple container:** set `OTEL_EXPORTER_OTLP_ENDPOINT` - **Kubernetes with the OpenTelemetry Operator:** the platform injects `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` -- **Datadog or another OTLP collector:** set the standard OTel endpoint and resource attributes +- **Datadog or another OTLP collector:** set the standard OpenTelemetry endpoint and resource attributes - **Local development:** leave the variables unset and use the built-in `localhost:4318` fallback -> [!NOTE] -> Lower `OTEL_TRACES_SAMPLER_ARG` in high-throughput production. Capturing 100% of the traces increases CPU overhead +> Note: Lower `OTEL_TRACES_SAMPLER_ARG` in high-throughput production. Capturing 100% of the traces increases CPU overhead > and vendor costs. For example, `0.1` keeps 10% of the traces. -#### Example: Docker deployment with Datadog - -This example sends traces and metrics to a Datadog Agent exposing the OTLP receiver on port `4318`. - -```bash -docker run -d --name idp-core \ - -e OTEL_TRACING_ENABLED=true \ - -e OTEL_METRICS_EXPORT_ENABLED=true \ - -e OTEL_EXPORTER_OTLP_ENDPOINT=http://datadog-agent:4318 \ - -e OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \ - -e OTEL_TRACES_SAMPLER_ARG=0.2 \ - -e OTEL_SERVICE_NAME=idp-core \ - -e OTEL_RESOURCE_ATTRIBUTES="deployment.environment=prod,service.namespace=idp-core,service.name=internal-developer-platform,service.version=1.0.0,team=platform" \ - idp-core:latest -``` - -`OTEL_RESOURCE_ATTRIBUTES` is the right place to pass Datadog tags such as `env`, `service`, and `version`. The application does not need hard-coded Datadog values; it just emits the standard OTel resource metadata. +`OTEL_RESOURCE_ATTRIBUTES` is the right place to pass Datadog tags such as `env`, `service`, and `version`. The application does not need hard-coded Datadog values; it just emits the standard OpenTelemetry resource metadata. ## Troubleshooting From deb926e7e350f6f4ba1f120b9da4c59484f31a5f Mon Sep 17 00:00:00 2001 From: foukou19 Date: Wed, 14 Oct 2026 11:29:08 +0200 Subject: [PATCH 10/10] feat(config): align observability docs with Spring Boot OTLP config Signed-off-by: foukou19 --- docs/src/deployment/configuration.md | 50 +++- docs/src/deployment/observability.md | 366 ++++++--------------------- 2 files changed, 115 insertions(+), 301 deletions(-) diff --git a/docs/src/deployment/configuration.md b/docs/src/deployment/configuration.md index 276b89b6..bd769394 100644 --- a/docs/src/deployment/configuration.md +++ b/docs/src/deployment/configuration.md @@ -190,21 +190,47 @@ spring: ```yaml management: tracing: - enabled: true sampling: - probability: 1.0 - -otel: - exporter: - otlp: - endpoint: http://otel-collector:4317 - service: - name: idp-core - resource: - attributes: - deployment.environment: production + probability: ${OTEL_TRACES_SAMPLER_ARG:0.1} + export: + enabled: ${OTEL_TRACING_ENABLED:false} + + otlp: + metrics: + export: + url: ${OTEL_EXPORTER_OTLP_METRICS_ENDPOINT:${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/metrics} + enabled: ${OTEL_METRICS_EXPORT_ENABLED:false} + transport: ${OTEL_EXPORTER_OTLP_METRICS_PROTOCOL:${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf}} + + opentelemetry: + tracing: + export: + otlp: + endpoint: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/traces} + transport: ${OTEL_EXPORTER_OTLP_TRACES_TRANSPORT:${OTEL_EXPORTER_OTLP_PROTOCOL:http}} + resource-attributes: + deployment.environment: ${OTEL_ENV:local} + service.namespace: ${OTEL_SERVICE_NAMESPACE:idp-core} + service.name: ${OTEL_SERVICE_NAME:internal-developer-platform} ``` +The signal-specific OpenTelemetry variables win over the generic fallback, so you can set the standard OpenTelemetry environment variables without hard-coding vendor-specific values. + +### Common environment variables + +| Variable | Description | +| --- | --- | +| `OTEL_TRACING_ENABLED` | Enables tracing export | +| `OTEL_METRICS_EXPORT_ENABLED` | Enables OTLP metrics export | +| `OTEL_TRACES_SAMPLER_ARG` | Trace sampling ratio | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | Base OTLP endpoint | +| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | Full traces URL | +| `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | Full metrics URL | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | Generic OTLP protocol fallback | +| `OTEL_EXPORTER_OTLP_TRACES_TRANSPORT` | Trace transport override | +| `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL` | Metrics transport override | +| `OTEL_RESOURCE_ATTRIBUTES` | Resource metadata for the service | + --- ### OAuth 2.0 diff --git a/docs/src/deployment/observability.md b/docs/src/deployment/observability.md index ccbea54a..e0d179e4 100644 --- a/docs/src/deployment/observability.md +++ b/docs/src/deployment/observability.md @@ -3,336 +3,124 @@ title: Observability description: Monitoring, logging, and tracing for the Internal Developer Platform --- -The Internal Developer Platform provides comprehensive observability through metrics, logs, and distributed tracing using OpenTelemetry. - -## Overview +The Internal Developer Platform emits metrics, logs, and traces through Spring Boot and OpenTelemetry, with Camel instrumentation enabled for the webhook ingestion flow. ```mermaid flowchart LR - subgraph "Internal Developer Platform" - M[Metrics] - L[Logs] - T[Traces] + subgraph "Application" + APP[IDP-Core] + CAMEL[Camel route] + OTEL[OpenTelemetry] end - subgraph "Collection" - OTEL[OpenTelemetry Collector] + subgraph "Backends" + DD[Datadog / OTLP collector] PROM[Prometheus] end - subgraph "Visualization" - GRAF[Grafana] - LOKI[Loki] - TEMPO[Tempo / Jaeger] - DATADOG[Datadog] - end - - M --> PROM --> GRAF - L --> OTEL --> LOKI --> GRAF - T --> OTEL --> TEMPO --> GRAF - OTEL --> DATADOG - PROM --> DATADOG + APP --> CAMEL + CAMEL --> OTEL + OTEL --> DD + OTEL --> PROM ``` ---- +The app exposes health endpoints and uses OTLP export for traces and metrics when enabled. The standard OpenTelemetry variables remain the integration point for the environment, not vendor-specific configuration. ## Metrics -### Prometheus Endpoint - -The Internal Developer Platform exposes metrics at `/actuator/prometheus`: - -```bash -curl http://localhost:8080/actuator/prometheus -``` - -### Key Metrics - -#### Application Metrics - -| Metric | Description | -| -------- | ------------- | -| `idp_entities_total` | Total entities by template | -| `idp_entity_operations_total` | CRUD operations count | -| `idp_webhook_invocations_total` | Webhook calls | -| `idp_action_executions_total` | Self-service action runs | - -#### HTTP Metrics - -| Metric | Description | -| -------- | ------------- | -| `http_server_requests_seconds` | Request latency | -| `http_server_requests_active` | Active requests | - -#### JVM Metrics - -| Metric | Description | -| -------- | ------------- | -| `jvm_memory_used_bytes` | Memory usage | -| `jvm_gc_pause_seconds` | GC pause time | -| `jvm_threads_live` | Thread count | +### Metrics exported by Spring Boot and Micrometer -#### Database Metrics - -| Metric | Description | -| -------- | ------------- | -| `hikaricp_connections_active` | Active DB connections | -| `hikaricp_connections_pending` | Waiting connections | - -### Metrics Configuration +The application exposes the standard Micrometer metrics and OTLP export can be enabled through environment variables. ```yaml management: - endpoints: - web: - exposure: - include: health,info,metrics,prometheus - metrics: + tracing: + sampling: + probability: ${OTEL_TRACES_SAMPLER_ARG:0.1} export: - prometheus: - enabled: true - distribution: - percentiles-histogram: - http.server.requests: true - percentiles: - http.server.requests: 0.5,0.95,0.99 - tags: - application: idp-core - environment: ${ENVIRONMENT:dev} -``` - -### Prometheus scrape configuration - -```yaml title="prometheus.yml" -scrape_configs: - - job_name: 'idp-core' - metrics_path: '/actuator/prometheus' - static_configs: - - targets: ['idp-core:8080'] - scrape_interval: 15s -``` - -### Kubernetes ServiceMonitor - -```yaml title="servicemonitor.yaml" -apiVersion: monitoring.coreos.com/v1 -kind: ServiceMonitor -metadata: - name: idp-core - namespace: idp -spec: - selector: - matchLabels: - app: idp-core - endpoints: - - port: http - path: /actuator/prometheus - interval: 30s -``` - ---- - -## Logging + enabled: ${OTEL_TRACING_ENABLED:false} -### Log Format - -#### Development (Human-readable) - -```text -2024-01-15 10:30:45.123 INFO [http-nio-8080-exec-1] c.d.i.a.EntityController - Creating entity: payment-service -``` - -#### Production (JSON) - -```json -{ - "timestamp": "2024-01-15T10:30:45.123Z", - "level": "INFO", - "logger": "c.d.i.a.EntityController", - "message": "Creating entity: payment-service", - "thread": "http-nio-8080-exec-1", - "traceId": "abc123def456", - "spanId": "789xyz" -} -``` - -### Logging Configuration - -```yaml -logging: - level: - root: INFO - com.decathlon.idp_core: INFO - org.springframework.web: WARN - org.hibernate: WARN - - pattern: - # JSON for production - console: '{"timestamp":"%d{ISO8601}","level":"%level","logger":"%logger{36}","message":"%msg","thread":"%thread","traceId":"%X{traceId}","spanId":"%X{spanId}"}%n' -``` - -### Loki Integration - -Send logs to Loki via the OpenTelemetry Collector: - -```yaml title="otel-collector-config.yaml" -receivers: otlp: - protocols: - grpc: - endpoint: 0.0.0.0:4317 - -exporters: - loki: - endpoint: http://loki:3100/loki/api/v1/push - labels: - attributes: - service.name: "service_name" - deployment.environment: "environment" - -service: - pipelines: - logs: - receivers: [otlp] - exporters: [loki] + metrics: + export: + url: ${OTEL_EXPORTER_OTLP_METRICS_ENDPOINT:${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/metrics} + enabled: ${OTEL_METRICS_EXPORT_ENABLED:false} + transport: ${OTEL_EXPORTER_OTLP_METRICS_PROTOCOL:${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf}} + + opentelemetry: + tracing: + export: + otlp: + endpoint: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/traces} + transport: ${OTEL_EXPORTER_OTLP_TRACES_TRANSPORT:${OTEL_EXPORTER_OTLP_PROTOCOL:http}} + resource-attributes: + deployment.environment: ${OTEL_ENV:local} + service.namespace: ${OTEL_SERVICE_NAMESPACE:idp-core} + service.name: ${OTEL_SERVICE_NAME:internal-developer-platform} ``` -### Log Levels +### Custom business metrics -| Level | Usage | -| ------- | -------------------------- | -| `ERROR` | Errors requiring attention | -| `WARN` | Potential issues | -| `INFO` | Key business events | -| `DEBUG` | Development debugging | -| `TRACE` | Detailed tracing | +Business-level metrics are intentionally separate from Camel route telemetry. The application adds custom counters only for functional decisions that Camel does not expose as a reusable business signal. ---- +Examples: -## Distributed Tracing +- `idp.webhook.security.requests.total{connector, security_type}` +- `idp.webhook.security.result{connector, security_type, result}` +- `idp.webhook.security.validation.errors.total{connector, security_type, reason}` -### OpenTelemetry Setup +These metrics reflect the real security decision made by the application and should not duplicate the standard Camel `route.*` metrics. -```yaml -management: - tracing: - enabled: true - sampling: - probability: 1.0 # Sample all requests (use 0.1 for 10% in prod) - -otel: - exporter: - otlp: - endpoint: http://otel-collector:4317 - service: - name: idp-core - resource: - attributes: - deployment.environment: production - service.version: "1.0.0" -``` +## Logging -### Trace Context +Logs remain useful for debugging and operational incidents, but they are not the primary analytics signal. The main operational signals are traces and metrics. -IDP-Core propagates trace context through: +The application configuration keeps log filtering under `logging.level.*` and actuator health endpoints under `management.endpoint.health`. -- HTTP headers (W3C Trace Context) -- Kafka message headers -- Webhook requests +## Distributed tracing -### Key Spans +OpenTelemetry tracing is enabled through Spring Boot configuration. The application keeps the trace sampling rate and OTLP exporter configurable through environment variables. -| Span | Description | -| --------------------------- | -------------------- | -| `HTTP GET /api/v1/entities` | Incoming API request | -| `EntityRepository.findById` | Database query | -| `WebhookService.invoke` | Outgoing webhook | -| `KafkaProducer.send` | Kafka message | +### Standard environment variables -### Jaeger/Tempo Integration +| Variable | Description | Default | +| --- | --- | --- | +| `OTEL_TRACING_ENABLED` | Enables tracing export | `false` | +| `OTEL_METRICS_EXPORT_ENABLED` | Enables OTLP metrics export | `false` | +| `OTEL_TRACES_SAMPLER_ARG` | Trace sampling ratio | `0.1` | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | Base OTLP endpoint | `http://localhost:4318` | +| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | Full traces endpoint | `/v1/traces` | +| `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | Full metrics endpoint | `/v1/metrics` | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | Generic OTLP protocol fallback | `http/protobuf` for metrics, `http` for traces | +| `OTEL_EXPORTER_OTLP_TRACES_TRANSPORT` | Trace-specific transport override | `OTEL_EXPORTER_OTLP_PROTOCOL` or `http` | +| `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL` | Metrics-specific transport override | `OTEL_EXPORTER_OTLP_PROTOCOL` | +| `OTEL_RESOURCE_ATTRIBUTES` | Resource metadata injected by the environment | - | -```yaml title="otel-collector-config.yaml" -receivers: - otlp: - protocols: - grpc: - endpoint: 0.0.0.0:4317 - -exporters: - otlp/tempo: - endpoint: http://tempo:4317 - tls: - insecure: true - -service: - pipelines: - traces: - receivers: [otlp] - exporters: [otlp/tempo] -``` +The signal-specific variables win over the generic fallback, so local development and hosted deployments can both work without hard-coded vendor configuration. ---- +### Important trace attributes -## Health Checks +The application sets trace attributes for the webhook pipeline, including the connector identifier and the security decision. -### Endpoints +Examples: -| Endpoint | Purpose | -| ---------------------------- | --------------- | -| `/actuator/health` | Overall health | -| `/actuator/health/liveness` | Liveness probe | -| `/actuator/health/readiness` | Readiness probe | +- `idp.webhook.identifier` +- `idp.webhook.security.result` -### Health Configuration +These attributes support trace correlation in Datadog APM and allow support teams to trace a request across route execution, security validation, and mapping. -```yaml -management: - endpoint: - health: - show-details: when_authorized - show-components: always - probes: - enabled: true - health: - db: - enabled: true - diskspace: - enabled: true - threshold: 100MB -``` +## Health checks -### Response Example - -```json -{ - "status": "UP", - "components": { - "db": { - "status": "UP", - "details": { - "database": "PostgreSQL", - "validationQuery": "isValid()" - } - }, - "diskSpace": { - "status": "UP", - "details": { - "total": 107374182400, - "free": 85899345920, - "threshold": 104857600 - } - }, - "ping": { - "status": "UP" - } - } -} -``` +The app exposes health endpoints used by Docker and Kubernetes. ---- +| Endpoint | Purpose | +|------------------------------|----------------------| +| `/actuator/health` | Overall health | +| `/actuator/health/liveness` | Liveness probe | +| `/actuator/health/readiness` | Readiness probe | -## Next Steps +## Next steps -- **[Configuration](configuration.md)** - Observability settings -- **[Kubernetes](kubernetes.md)** - Production monitoring -- **[Docker](docker.md)** - Local observability setup +- **[Configuration](configuration.md)** - Environment and Spring Boot settings +- **[Docker](docker.md)** - Docker deployment and OTLP examples +- **[Kubernetes](kubernetes.md)** - Production deployment patterns