From b98b6303317f855913d037a710f925f743456bf9 Mon Sep 17 00:00:00 2001 From: foukou19 Date: Mon, 5 Oct 2026 11:26:05 +0200 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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 5924d23f9d60c3c0dc5bd0441b732dea0998d1cf Mon Sep 17 00:00:00 2001 From: foukou19 Date: Tue, 13 Oct 2026 15:43:46 +0200 Subject: [PATCH 08/14] feat(observability): add custom webhook security metrics Signed-off-by: foukou19 --- .../processor/SecurityProcessor.java | 44 ++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) 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 104f4ea4..b41db393 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 @@ -5,6 +5,7 @@ import org.springframework.stereotype.Component; +import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException; import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; @@ -12,6 +13,7 @@ import com.decathlon.idp_core.infrastructure.adapters.ingestion.exception.WebhookSecurityException; import com.decathlon.idp_core.infrastructure.adapters.ingestion.security.WebhookRequestAuthenticator; +import io.micrometer.core.instrument.MeterRegistry; import io.opentelemetry.api.trace.Span; import lombok.extern.slf4j.Slf4j; @@ -23,21 +25,34 @@ public class SecurityProcessor { 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 static final String RESULT_REJECTED = "REJECTED"; + private static final String RESULT_ERROR = "ERROR"; + private static final String METRIC_SECURITY_REQUESTS = "idp.webhook.security.requests.total"; + private static final String METRIC_SECURITY_RESULT = "idp.webhook.security.result"; + private static final String METRIC_SECURITY_VALIDATION_ERRORS = + "idp.webhook.security.validation.errors.total"; private final List strategies; + private final MeterRegistry meterRegistry; - public SecurityProcessor(List strategies) { + public SecurityProcessor(List strategies, MeterRegistry meterRegistry) { this.strategies = List.copyOf(strategies); + this.meterRegistry = meterRegistry; } public void validate(Map headers, byte[] rawPayload, WebhookConnector webhookConnector) { String webhookIdentifier = webhookConnector.identifier(); + WebhookSecurity security = webhookConnector.security(); + String securityType = security == null || security.type() == null ? WebhookSecurityType.NONE.name() + : security.type().name(); + + meterRegistry.counter(METRIC_SECURITY_REQUESTS, "connector", webhookIdentifier, + "security_type", securityType).increment(); Span.current().setAttribute(SPAN_ATTRIBUTE_WEBHOOK_IDENTIFIER, webhookIdentifier); - WebhookSecurity security = webhookConnector.security(); if (security == null || security.type() == WebhookSecurityType.NONE) { - Span.current().setAttribute(SPAN_ATTRIBUTE_SECURITY_RESULT, RESULT_SUCCESS); + recordSecurityResult(webhookIdentifier, securityType, RESULT_SUCCESS); return; } @@ -53,13 +68,32 @@ public void validate(Map headers, byte[] rawPayload, authenticator.validateRequest(headers, rawPayload == null ? new byte[0] : rawPayload, security.config()); - Span.current().setAttribute(SPAN_ATTRIBUTE_SECURITY_RESULT, RESULT_SUCCESS); + recordSecurityResult(webhookIdentifier, securityType, RESULT_SUCCESS); log.debug("Webhook security validation passed for connector '{}' with strategy '{}'.", webhookIdentifier, security.type()); } catch (RuntimeException exception) { - Span.current().setAttribute(SPAN_ATTRIBUTE_SECURITY_RESULT, "REJECTED"); + String result = isSecurityRejected(exception) ? RESULT_REJECTED : RESULT_ERROR; + recordSecurityResult(webhookIdentifier, securityType, result); + meterRegistry.counter(METRIC_SECURITY_VALIDATION_ERRORS, "connector", webhookIdentifier, + "security_type", securityType, "reason", classifyFailure(exception)).increment(); throw exception; } } + private void recordSecurityResult(String webhookIdentifier, String securityType, String result) { + meterRegistry.counter(METRIC_SECURITY_RESULT, "connector", webhookIdentifier, + "security_type", securityType, "result", result).increment(); + Span.current().setAttribute(SPAN_ATTRIBUTE_SECURITY_RESULT, result); + } + + private boolean isSecurityRejected(RuntimeException exception) { + return exception instanceof WebhookAuthenticationException + || exception instanceof WebhookSecurityException; + } + + private String classifyFailure(RuntimeException exception) { + String reason = exception.getClass().getSimpleName(); + return reason.isBlank() ? "unknown" : reason; + } + } From a598f662122dcdc36176912271a47e0cd42f1ac9 Mon Sep 17 00:00:00 2001 From: foukou19 Date: Wed, 14 Oct 2026 16:55:02 +0200 Subject: [PATCH 09/14] feat(config): add custom webhook ingestion metrics Signed-off-by: foukou19 --- .../IngestionMetricConstants.java | 35 +++ .../processor/IngestionProcessor.java | 212 +++++++++++++----- .../processor/SecurityProcessor.java | 17 +- .../processor/IngestionProcessorTest.java | 4 +- 4 files changed, 202 insertions(+), 66 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionMetricConstants.java diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionMetricConstants.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionMetricConstants.java new file mode 100644 index 00000000..a3a0c145 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionMetricConstants.java @@ -0,0 +1,35 @@ +package com.decathlon.idp_core.infrastructure.adapters.ingestion.configuration; + +/** + * Shared metric names for the ingestion workflow. + * + *

+ * These values are intentionally centralized so the routing and processing + * layers can reuse the same business metric names without duplicating string + * literals across processors. + */ +public final class IngestionMetricConstants { + + public static final String SECURITY_REQUESTS_TOTAL = "idp.webhook.security.requests.total"; + public static final String SECURITY_RESULT = "idp.webhook.security.result"; + public static final String SECURITY_VALIDATION_ERRORS_TOTAL = "idp.webhook.security.validation.errors.total"; + + public static final String MAPPING_APPLIED_TOTAL = "idp.webhook.mapping.applied.total"; + public static final String MAPPING_FILTERED_TOTAL = "idp.webhook.mapping.filtered.total"; + public static final String MAPPING_RESULT = "idp.webhook.mapping.result"; + public static final String MAPPING_ERRORS_TOTAL = "idp.webhook.mapping.errors.total"; + public static final String MAPPING_LATENCY_MS = "idp.webhook.mapping.latency.ms"; + + public static final String ENTITY_CREATED_TOTAL = "idp.webhook.entity.created.total"; + public static final String ENTITY_UPDATED_TOTAL = "idp.webhook.entity.updated.total"; + public static final String ENTITY_DELETED_TOTAL = "idp.webhook.entity.deleted.total"; + public static final String ENTITY_PATCH_TOTAL = "idp.webhook.entity.patch.total"; + public static final String ENTITY_VALIDATION_ERRORS_TOTAL = "idp.webhook.entity.validation.errors.total"; + + public static final String FUNCTIONAL_VALIDATION_FAILED_TOTAL = "idp.webhook.functional.validation.failed.total"; + public static final String ENTITY_NOT_FOUND_TOTAL = "idp.webhook.entity.notfound.total"; + public static final String ENTITY_CONFLICT_TOTAL = "idp.webhook.entity.conflict.total"; + + private IngestionMetricConstants() { + } +} 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 7a88233f..1563b5b4 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 @@ -4,14 +4,18 @@ import org.springframework.stereotype.Component; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityPatch; import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; import com.decathlon.idp_core.domain.port.MappingEnginePort; import com.decathlon.idp_core.domain.service.entity.EntityService; +import com.decathlon.idp_core.infrastructure.adapters.ingestion.configuration.IngestionMetricConstants; +import io.micrometer.core.instrument.MeterRegistry; import io.opentelemetry.api.trace.Span; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -25,6 +29,7 @@ public class IngestionProcessor { private final MappingEnginePort mappingEngine; private final EntityService entityService; + private final MeterRegistry meterRegistry; /// Ingests a webhook payload by applying all configured mappings. /// @@ -44,7 +49,8 @@ public void ingest(String payload, WebhookConnector webhookConnectorConfiguratio Span.current().setAttribute(SPAN_ATTRIBUTE_WEBHOOK_IDENTIFIER, connectorIdentifier); log.info("Starting ingestion for webhook connector: {}", connectorIdentifier); - webhookConnectorConfiguration.mappings().forEach(mapping -> applyMapping(payload, mapping)); + webhookConnectorConfiguration.mappings() + .forEach(mapping -> applyMapping(payload, mapping, webhookConnectorConfiguration)); log.info("Completed ingestion for webhook connector: {}", connectorIdentifier); } @@ -54,89 +60,183 @@ public void ingest(String payload, WebhookConnector webhookConnectorConfiguratio /// /// @param payload the raw JSON payload from the webhook /// @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; - } - 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()); + private void applyMapping(String payload, EntityDynamicMapping mapping, WebhookConnector webhookConnectorConfiguration) { + String connectorIdentifier = webhookConnectorConfiguration.identifier(); + String mappingId = mapping.identifier(); + String action = mapping.action() == null ? "unknown" : mapping.action().name(); + long startTimeNanos = System.nanoTime(); + + meterRegistry.counter(IngestionMetricConstants.MAPPING_APPLIED_TOTAL, "connector", connectorIdentifier, "mapping_id", mappingId, "action", action).increment(); + + try { + Entity entity = mappingEngine.mapToEntity(payload, mapping); + + if (entity == null) { + meterRegistry.counter(IngestionMetricConstants.MAPPING_FILTERED_TOTAL, "connector", connectorIdentifier, + "mapping_id", mappingId, "reason", "filter_rejected").increment(); + meterRegistry.counter(IngestionMetricConstants.MAPPING_RESULT, "connector", connectorIdentifier, + "mapping_id", mappingId, "result", "FILTERED_OUT").increment(); + return; + } + + meterRegistry.counter(IngestionMetricConstants.MAPPING_RESULT, "connector", connectorIdentifier, + "mapping_id", mappingId, "result", "MATCHED").increment(); + + switch (mapping.action()) { + case UPDATE_ENTITY -> handleUpdate(entity, connectorIdentifier); + case UPDATE_PROPERTIES -> handleUpdateProperties(entity, connectorIdentifier); + case UPDATE_RELATIONS -> handleUpdateRelations(entity, connectorIdentifier); + case DELETE_ENTITY -> handleDelete(entity, connectorIdentifier); + 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()); + } catch (RuntimeException exception) { + meterRegistry.counter(IngestionMetricConstants.MAPPING_ERRORS_TOTAL, "connector", connectorIdentifier, + "mapping_id", mappingId, "error_type", exception.getClass().getSimpleName()) + .increment(); + meterRegistry.counter(IngestionMetricConstants.MAPPING_RESULT, "connector", connectorIdentifier, + "mapping_id", mappingId, "result", "ERROR").increment(); + meterRegistry.counter(IngestionMetricConstants.FUNCTIONAL_VALIDATION_FAILED_TOTAL, "connector", connectorIdentifier, + "mapping_id", mappingId, "reason", exception.getClass().getSimpleName()).increment(); + recordEntityFailureMetrics(connectorIdentifier, exception); + throw exception; + } finally { + long durationMs = (System.nanoTime() - startTimeNanos) / 1_000_000L; + meterRegistry.summary(IngestionMetricConstants.MAPPING_LATENCY_MS, "connector", connectorIdentifier, + "mapping_id", mappingId).record(durationMs); } - - log.info("Successfully processed action {} for entity: {} under template: {}", - mapping.action(), entity.identifier(), entity.templateIdentifier()); } /// Handles the Update action for an entity. /// /// If the entity exists, it is patched with the new data. If the entity does - /// not - /// exist, it is created. + /// not exist, it is created. /// /// @param entity the entity to Update - private void handleUpdate(Entity entity) { - if (entityService.entityExists(entity.templateIdentifier(), entity.identifier())) { - log.debug("Patching entity {} for template: {}", entity.identifier(), - entity.templateIdentifier()); - entityService.patchEntity(entity.templateIdentifier(), entity.identifier(), - EntityPatch.fromEntity(entity)); - } else { - log.debug("Creating entity {} for template: {}", entity.identifier(), - entity.templateIdentifier()); - entityService.createEntity(entity); + private void handleUpdate(Entity entity, String connectorIdentifier) { + try { + if (entityService.entityExists(entity.templateIdentifier(), entity.identifier())) { + log.debug("Patching entity {} for template: {}", entity.identifier(), + entity.templateIdentifier()); + entityService.patchEntity(entity.templateIdentifier(), entity.identifier(), + EntityPatch.fromEntity(entity)); + meterRegistry.counter(IngestionMetricConstants.ENTITY_PATCH_TOTAL, "template", + entity.templateIdentifier(), "connector", connectorIdentifier, "action", + "UPDATE_ENTITY").increment(); + meterRegistry.counter(IngestionMetricConstants.ENTITY_UPDATED_TOTAL, "template", + entity.templateIdentifier(), "connector", connectorIdentifier).increment(); + } else { + log.debug("Creating entity {} for template: {}", entity.identifier(), + entity.templateIdentifier()); + entityService.createEntity(entity); + meterRegistry.counter(IngestionMetricConstants.ENTITY_CREATED_TOTAL, "template", + entity.templateIdentifier(), "connector", connectorIdentifier).increment(); + } + } catch (RuntimeException exception) { + recordEntityFailureMetrics(connectorIdentifier, exception, entity.templateIdentifier()); + throw exception; } } /// Handles the Update properties action for an entity. /// /// If the entity exists, only its properties are patched. If the entity does - /// not - /// exist, it is created with only its properties. + /// not exist, it is created with only its properties. /// /// @param entity the entity to Update properties for - private void handleUpdateProperties(Entity entity) { + private void handleUpdateProperties(Entity entity, String connectorIdentifier) { EntityPatch propertiesPatch = new EntityPatch(entity.name(), entity.properties(), List.of()); - if (entityService.entityExists(entity.templateIdentifier(), entity.identifier())) { - entityService.patchEntity(entity.templateIdentifier(), entity.identifier(), propertiesPatch); - } else { - entityService.createEntity(new Entity(null, entity.templateIdentifier(), entity.name(), - entity.identifier(), entity.properties(), List.of())); + try { + if (entityService.entityExists(entity.templateIdentifier(), entity.identifier())) { + entityService.patchEntity(entity.templateIdentifier(), entity.identifier(), + propertiesPatch); + meterRegistry.counter(IngestionMetricConstants.ENTITY_PATCH_TOTAL, "template", + entity.templateIdentifier(), "connector", connectorIdentifier, "action", + "UPDATE_PROPERTIES").increment(); + meterRegistry.counter(IngestionMetricConstants.ENTITY_UPDATED_TOTAL, "template", + entity.templateIdentifier(), "connector", connectorIdentifier).increment(); + } else { + entityService.createEntity(new Entity(null, entity.templateIdentifier(), entity.name(), + entity.identifier(), entity.properties(), List.of())); + meterRegistry.counter(IngestionMetricConstants.ENTITY_CREATED_TOTAL, "template", + entity.templateIdentifier(), "connector", connectorIdentifier).increment(); + } + } catch (RuntimeException exception) { + recordEntityFailureMetrics(connectorIdentifier, exception, entity.templateIdentifier()); + throw exception; } } /// Handles the Update relations action for an entity. If the entity exists, - /// only its relations are patched. The entity must exists in order to patch the - /// relations + /// only its relations are patched. The entity must exist in order to patch the + /// relations. /// - /// @param entity the entity to Update relations for - /// @throws EntityNotFoundException - private void handleUpdateRelations(Entity entity) { - - if (!entityService.entityExists(entity.templateIdentifier(), entity.identifier())) { - throw new EntityNotFoundException(entity.templateIdentifier(), entity.identifier()); + /// @param entity the entity to update relations for + /// @throws EntityNotFoundException when the entity does not exist + private void handleUpdateRelations(Entity entity, String connectorIdentifier) { + try { + if (!entityService.entityExists(entity.templateIdentifier(), entity.identifier())) { + throw new EntityNotFoundException(entity.templateIdentifier(), entity.identifier()); + } + EntityPatch relationsPatch = new EntityPatch(null, List.of(), entity.relations()); + + entityService.patchEntity(entity.templateIdentifier(), entity.identifier(), relationsPatch); + meterRegistry.counter(IngestionMetricConstants.ENTITY_PATCH_TOTAL, "template", + entity.templateIdentifier(), "connector", connectorIdentifier, "action", + "UPDATE_RELATIONS").increment(); + meterRegistry.counter(IngestionMetricConstants.ENTITY_UPDATED_TOTAL, "template", + entity.templateIdentifier(), "connector", connectorIdentifier).increment(); + } catch (RuntimeException exception) { + recordEntityFailureMetrics(connectorIdentifier, exception, entity.templateIdentifier()); + throw exception; } - // Strip properties before invoking entity service - EntityPatch relationsPatch = new EntityPatch(null, List.of(), entity.relations()); - - entityService.patchEntity(entity.templateIdentifier(), entity.identifier(), relationsPatch); - } /// Handles the delete action for an entity. /// /// @param entity the entity to delete - private void handleDelete(Entity entity) { - entityService.deleteEntity(entity.templateIdentifier(), entity.identifier()); - log.debug("Deleted entity: {} for template: {}", entity.identifier(), - entity.templateIdentifier()); + private void handleDelete(Entity entity, String connectorIdentifier) { + try { + entityService.deleteEntity(entity.templateIdentifier(), entity.identifier()); + meterRegistry.counter(IngestionMetricConstants.ENTITY_DELETED_TOTAL, "template", + entity.templateIdentifier(), "connector", connectorIdentifier).increment(); + log.debug("Deleted entity: {} for template: {}", entity.identifier(), + entity.templateIdentifier()); + } catch (RuntimeException exception) { + recordEntityFailureMetrics(connectorIdentifier, exception, entity.templateIdentifier()); + throw exception; + } + } + + private void recordEntityFailureMetrics(String connectorIdentifier, RuntimeException exception) { + recordEntityFailureMetrics(connectorIdentifier, exception, "unknown"); + } + + private void recordEntityFailureMetrics(String connectorIdentifier, RuntimeException exception, + String templateIdentifier) { + meterRegistry.counter(IngestionMetricConstants.ENTITY_VALIDATION_ERRORS_TOTAL, "template", + templateIdentifier, "connector", connectorIdentifier, "reason", + exception.getClass().getSimpleName()).increment(); + meterRegistry.counter(IngestionMetricConstants.FUNCTIONAL_VALIDATION_FAILED_TOTAL, "connector", + connectorIdentifier, "template", templateIdentifier, "reason", + exception.getClass().getSimpleName()).increment(); + + if (exception instanceof EntityNotFoundException) { + meterRegistry.counter(IngestionMetricConstants.ENTITY_NOT_FOUND_TOTAL, "template", + templateIdentifier, "connector", connectorIdentifier, "action", "ingestion").increment(); + } + if (exception instanceof EntityAlreadyExistsException) { + meterRegistry.counter(IngestionMetricConstants.ENTITY_CONFLICT_TOTAL, "template", + templateIdentifier, "connector", connectorIdentifier, "reason", "already_exists") + .increment(); + } + if (exception instanceof EntityValidationException) { + meterRegistry.counter(IngestionMetricConstants.ENTITY_VALIDATION_ERRORS_TOTAL, "template", + templateIdentifier, "connector", connectorIdentifier, "reason", + exception.getClass().getSimpleName()).increment(); + } } } 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 30daeff7..7432107f 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 @@ -10,6 +10,7 @@ import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy; +import com.decathlon.idp_core.infrastructure.adapters.ingestion.configuration.IngestionMetricConstants; import com.decathlon.idp_core.infrastructure.adapters.ingestion.exception.WebhookSecurityException; import com.decathlon.idp_core.infrastructure.adapters.ingestion.security.WebhookRequestAuthenticator; @@ -27,9 +28,6 @@ public class SecurityProcessor { private static final String RESULT_SUCCESS = "SUCCESS"; private static final String RESULT_REJECTED = "REJECTED"; private static final String RESULT_ERROR = "ERROR"; - private static final String METRIC_SECURITY_REQUESTS = "idp.webhook.security.requests.total"; - private static final String METRIC_SECURITY_RESULT = "idp.webhook.security.result"; - private static final String METRIC_SECURITY_VALIDATION_ERRORS = "idp.webhook.security.validation.errors.total"; private final List strategies; private final MeterRegistry meterRegistry; @@ -47,8 +45,8 @@ public void validate(Map headers, byte[] rawPayload, ? WebhookSecurityType.NONE.name() : security.type().name(); - meterRegistry.counter(METRIC_SECURITY_REQUESTS, "connector", webhookIdentifier, "security_type", - securityType).increment(); + meterRegistry.counter(IngestionMetricConstants.SECURITY_REQUESTS_TOTAL, "connector", + webhookIdentifier, "security_type", securityType).increment(); Span.current().setAttribute(SPAN_ATTRIBUTE_WEBHOOK_IDENTIFIER, webhookIdentifier); if (security == null || security.type() == WebhookSecurityType.NONE) { @@ -74,15 +72,16 @@ public void validate(Map headers, byte[] rawPayload, } catch (RuntimeException exception) { String result = isSecurityRejected(exception) ? RESULT_REJECTED : RESULT_ERROR; recordSecurityResult(webhookIdentifier, securityType, result); - meterRegistry.counter(METRIC_SECURITY_VALIDATION_ERRORS, "connector", webhookIdentifier, - "security_type", securityType, "reason", classifyFailure(exception)).increment(); + meterRegistry.counter(IngestionMetricConstants.SECURITY_VALIDATION_ERRORS_TOTAL, "connector", + webhookIdentifier, "security_type", securityType, "reason", classifyFailure(exception)) + .increment(); throw exception; } } private void recordSecurityResult(String webhookIdentifier, String securityType, String result) { - meterRegistry.counter(METRIC_SECURITY_RESULT, "connector", webhookIdentifier, "security_type", - securityType, "result", result).increment(); + meterRegistry.counter(IngestionMetricConstants.SECURITY_RESULT, "connector", webhookIdentifier, + "security_type", securityType, "result", result).increment(); Span.current().setAttribute(SPAN_ATTRIBUTE_SECURITY_RESULT, result); } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/IngestionProcessorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/IngestionProcessorTest.java index 67855a54..5aaf873b 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/IngestionProcessorTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/IngestionProcessorTest.java @@ -41,6 +41,7 @@ import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.read.ListAppender; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; /// Unit tests for IngestionProcessor covering all mapping actions and edge cases. /// @@ -70,7 +71,8 @@ void setUp() { listAppender = new ListAppender<>(); listAppender.start(); logger.addAppender(listAppender); - ingestionProcessor = new IngestionProcessor(mappingEngine, entityService); + ingestionProcessor = new IngestionProcessor(mappingEngine, entityService, + new SimpleMeterRegistry()); } private Entity createTestEntity(String templateId, String identifier) { From ad98b66dc75b6d0d2623d6c18c878a452ef3aeed Mon Sep 17 00:00:00 2001 From: foukou19 Date: Thu, 15 Oct 2026 17:29:05 +0200 Subject: [PATCH 10/14] feat(config): centralize webhook metrics and tracing Signed-off-by: foukou19 Signed-off-by: foukou19 --- pom.xml | 11 ++ .../IngestionMetricConstants.java | 4 + .../IngestionMetricsConfiguration.java | 27 ++++ .../IngestionObservabilitySupport.java | 44 ++++++ .../processor/IngestionProcessor.java | 149 +++++++++--------- .../processor/SecurityProcessor.java | 36 +++-- .../processor/decoder/DecodingProcessor.java | 107 +++++++++---- .../processor/DecodingProcessorTest.java | 12 +- .../processor/IngestionProcessorTest.java | 16 +- 9 files changed, 277 insertions(+), 129 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionMetricsConfiguration.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/metrics/IngestionObservabilitySupport.java diff --git a/pom.xml b/pom.xml index 50dff004..9ae338a1 100644 --- a/pom.xml +++ b/pom.xml @@ -185,6 +185,12 @@ test + + io.opentelemetry + opentelemetry-sdk-testing + test + + org.mock-server mockserver-client-java @@ -352,6 +358,11 @@ camel-micrometer-starter + + io.opentelemetry + opentelemetry-api + + diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionMetricConstants.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionMetricConstants.java index a3a0c145..a8f1261d 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionMetricConstants.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionMetricConstants.java @@ -14,6 +14,10 @@ public final class IngestionMetricConstants { public static final String SECURITY_RESULT = "idp.webhook.security.result"; public static final String SECURITY_VALIDATION_ERRORS_TOTAL = "idp.webhook.security.validation.errors.total"; + public static final String PAYLOAD_DECODED_TOTAL = "idp.webhook.payload.decoded.total"; + public static final String PAYLOAD_DECODE_ERRORS_TOTAL = "idp.webhook.payload.decode.errors.total"; + public static final String PAYLOAD_SIZE_BYTES = "idp.webhook.payload.size.bytes"; + public static final String MAPPING_APPLIED_TOTAL = "idp.webhook.mapping.applied.total"; public static final String MAPPING_FILTERED_TOTAL = "idp.webhook.mapping.filtered.total"; public static final String MAPPING_RESULT = "idp.webhook.mapping.result"; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionMetricsConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionMetricsConfiguration.java new file mode 100644 index 00000000..0d5b7979 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionMetricsConfiguration.java @@ -0,0 +1,27 @@ +package com.decathlon.idp_core.infrastructure.adapters.ingestion.configuration; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.metrics.Meter; + +@Configuration +public class IngestionMetricsConfiguration { + + @Bean("ingestionMeter") + public Meter ingestionMeter(OpenTelemetry openTelemetry) { + return openTelemetry.getMeterProvider().meterBuilder("com.decathlon.idp_core.ingestion") + .build(); + } + + @Bean("securityMeter") + public Meter securityMeter(OpenTelemetry openTelemetry) { + return openTelemetry.getMeterProvider().meterBuilder("com.decathlon.idp_core.security").build(); + } + + @Bean("decodingMeter") + public Meter decodingMeter(OpenTelemetry openTelemetry) { + return openTelemetry.getMeterProvider().meterBuilder("com.decathlon.idp_core.decoding").build(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/metrics/IngestionObservabilitySupport.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/metrics/IngestionObservabilitySupport.java new file mode 100644 index 00000000..15f1cf14 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/metrics/IngestionObservabilitySupport.java @@ -0,0 +1,44 @@ +package com.decathlon.idp_core.infrastructure.adapters.ingestion.metrics; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.LongHistogram; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.trace.Span; + +public final class IngestionObservabilitySupport { + + private IngestionObservabilitySupport() { + } + + public static void incrementCounter(Meter meter, String metricName, String... attributes) { + meter.counterBuilder(metricName).build().add(1, toAttributes(attributes)); + } + + public static void recordHistogram(Meter meter, String metricName, long value, + String... attributes) { + LongHistogram histogram = meter.histogramBuilder(metricName).ofLongs().build(); + histogram.record(value, toAttributes(attributes)); + } + + public static void setSpanAttribute(Span span, String attributeName, String attributeValue) { + if (span == null || attributeName == null || attributeName.isBlank() + || attributeValue == null) { + return; + } + span.setAttribute(attributeName, attributeValue); + } + + public static Attributes toAttributes(String... keyValues) { + if (keyValues == null || keyValues.length == 0) { + return Attributes.empty(); + } + if ((keyValues.length % 2) != 0) { + throw new IllegalArgumentException("Metric attributes must be provided as key/value pairs"); + } + var builder = Attributes.builder(); + for (int i = 0; i < keyValues.length; i += 2) { + builder.put(keyValues[i], keyValues[i + 1]); + } + return builder.build(); + } +} 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 1563b5b4..548dd7cc 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 @@ -2,6 +2,7 @@ import java.util.List; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; @@ -14,22 +15,28 @@ import com.decathlon.idp_core.domain.port.MappingEnginePort; import com.decathlon.idp_core.domain.service.entity.EntityService; import com.decathlon.idp_core.infrastructure.adapters.ingestion.configuration.IngestionMetricConstants; +import com.decathlon.idp_core.infrastructure.adapters.ingestion.metrics.IngestionObservabilitySupport; -import io.micrometer.core.instrument.MeterRegistry; +import io.opentelemetry.api.metrics.Meter; import io.opentelemetry.api.trace.Span; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Component @Slf4j -@RequiredArgsConstructor public class IngestionProcessor { private static final String SPAN_ATTRIBUTE_WEBHOOK_IDENTIFIER = "idp.webhook.identifier"; private final MappingEnginePort mappingEngine; private final EntityService entityService; - private final MeterRegistry meterRegistry; + private final Meter meter; + + public IngestionProcessor(MappingEnginePort mappingEngine, EntityService entityService, + @Qualifier("ingestionMeter") Meter meter) { + this.mappingEngine = mappingEngine; + this.entityService = entityService; + this.meter = meter; + } /// Ingests a webhook payload by applying all configured mappings. /// @@ -46,7 +53,9 @@ 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(SPAN_ATTRIBUTE_WEBHOOK_IDENTIFIER, connectorIdentifier); + Span currentSpan = Span.current(); + IngestionObservabilitySupport.setSpanAttribute(currentSpan, SPAN_ATTRIBUTE_WEBHOOK_IDENTIFIER, + connectorIdentifier); log.info("Starting ingestion for webhook connector: {}", connectorIdentifier); webhookConnectorConfiguration.mappings() @@ -54,33 +63,30 @@ public void ingest(String payload, WebhookConnector webhookConnectorConfiguratio log.info("Completed ingestion for webhook connector: {}", connectorIdentifier); } - /// Applies a single mapping to the payload and persists the resulting entity. - /// - /// Skips silently if the mapping filter excludes the payload (returns null). - /// - /// @param payload the raw JSON payload from the webhook - /// @param mapping the mapping definition to apply - private void applyMapping(String payload, EntityDynamicMapping mapping, WebhookConnector webhookConnectorConfiguration) { + private void applyMapping(String payload, EntityDynamicMapping mapping, + WebhookConnector webhookConnectorConfiguration) { String connectorIdentifier = webhookConnectorConfiguration.identifier(); String mappingId = mapping.identifier(); String action = mapping.action() == null ? "unknown" : mapping.action().name(); long startTimeNanos = System.nanoTime(); - meterRegistry.counter(IngestionMetricConstants.MAPPING_APPLIED_TOTAL, "connector", connectorIdentifier, "mapping_id", mappingId, "action", action).increment(); + incrementCounter(IngestionMetricConstants.MAPPING_APPLIED_TOTAL, + "connector", connectorIdentifier, "mapping_id", mappingId, "action", action); try { Entity entity = mappingEngine.mapToEntity(payload, mapping); if (entity == null) { - meterRegistry.counter(IngestionMetricConstants.MAPPING_FILTERED_TOTAL, "connector", connectorIdentifier, - "mapping_id", mappingId, "reason", "filter_rejected").increment(); - meterRegistry.counter(IngestionMetricConstants.MAPPING_RESULT, "connector", connectorIdentifier, - "mapping_id", mappingId, "result", "FILTERED_OUT").increment(); + incrementCounter(IngestionMetricConstants.MAPPING_FILTERED_TOTAL, + "connector", connectorIdentifier, "mapping_id", mappingId, "reason", + "filter_rejected"); + incrementCounter(IngestionMetricConstants.MAPPING_RESULT, + "connector", connectorIdentifier, "mapping_id", mappingId, "result", "FILTERED_OUT"); return; } - meterRegistry.counter(IngestionMetricConstants.MAPPING_RESULT, "connector", connectorIdentifier, - "mapping_id", mappingId, "result", "MATCHED").increment(); + incrementCounter(IngestionMetricConstants.MAPPING_RESULT, + "connector", connectorIdentifier, "mapping_id", mappingId, "result", "MATCHED"); switch (mapping.action()) { case UPDATE_ENTITY -> handleUpdate(entity, connectorIdentifier); @@ -93,28 +99,23 @@ private void applyMapping(String payload, EntityDynamicMapping mapping, WebhookC log.info("Successfully processed action {} for entity: {} under template: {}", mapping.action(), entity.identifier(), entity.templateIdentifier()); } catch (RuntimeException exception) { - meterRegistry.counter(IngestionMetricConstants.MAPPING_ERRORS_TOTAL, "connector", connectorIdentifier, - "mapping_id", mappingId, "error_type", exception.getClass().getSimpleName()) - .increment(); - meterRegistry.counter(IngestionMetricConstants.MAPPING_RESULT, "connector", connectorIdentifier, - "mapping_id", mappingId, "result", "ERROR").increment(); - meterRegistry.counter(IngestionMetricConstants.FUNCTIONAL_VALIDATION_FAILED_TOTAL, "connector", connectorIdentifier, - "mapping_id", mappingId, "reason", exception.getClass().getSimpleName()).increment(); + incrementCounter(IngestionMetricConstants.MAPPING_ERRORS_TOTAL, + "connector", connectorIdentifier, "mapping_id", mappingId, + "error_type", exception.getClass().getSimpleName()); + incrementCounter(IngestionMetricConstants.MAPPING_RESULT, + "connector", connectorIdentifier, "mapping_id", mappingId, "result", "ERROR"); + incrementCounter(IngestionMetricConstants.FUNCTIONAL_VALIDATION_FAILED_TOTAL, + "connector", connectorIdentifier, "mapping_id", mappingId, + "reason", exception.getClass().getSimpleName()); recordEntityFailureMetrics(connectorIdentifier, exception); throw exception; } finally { long durationMs = (System.nanoTime() - startTimeNanos) / 1_000_000L; - meterRegistry.summary(IngestionMetricConstants.MAPPING_LATENCY_MS, "connector", connectorIdentifier, - "mapping_id", mappingId).record(durationMs); + recordHistogram(IngestionMetricConstants.MAPPING_LATENCY_MS, durationMs, + "connector", connectorIdentifier, "mapping_id", mappingId); } } - /// Handles the Update action for an entity. - /// - /// If the entity exists, it is patched with the new data. If the entity does - /// not exist, it is created. - /// - /// @param entity the entity to Update private void handleUpdate(Entity entity, String connectorIdentifier) { try { if (entityService.entityExists(entity.templateIdentifier(), entity.identifier())) { @@ -122,17 +123,17 @@ private void handleUpdate(Entity entity, String connectorIdentifier) { entity.templateIdentifier()); entityService.patchEntity(entity.templateIdentifier(), entity.identifier(), EntityPatch.fromEntity(entity)); - meterRegistry.counter(IngestionMetricConstants.ENTITY_PATCH_TOTAL, "template", + incrementCounter(IngestionMetricConstants.ENTITY_PATCH_TOTAL, "template", entity.templateIdentifier(), "connector", connectorIdentifier, "action", - "UPDATE_ENTITY").increment(); - meterRegistry.counter(IngestionMetricConstants.ENTITY_UPDATED_TOTAL, "template", - entity.templateIdentifier(), "connector", connectorIdentifier).increment(); + "UPDATE_ENTITY"); + incrementCounter(IngestionMetricConstants.ENTITY_UPDATED_TOTAL, "template", + entity.templateIdentifier(), "connector", connectorIdentifier); } else { log.debug("Creating entity {} for template: {}", entity.identifier(), entity.templateIdentifier()); entityService.createEntity(entity); - meterRegistry.counter(IngestionMetricConstants.ENTITY_CREATED_TOTAL, "template", - entity.templateIdentifier(), "connector", connectorIdentifier).increment(); + incrementCounter(IngestionMetricConstants.ENTITY_CREATED_TOTAL, "template", + entity.templateIdentifier(), "connector", connectorIdentifier); } } catch (RuntimeException exception) { recordEntityFailureMetrics(connectorIdentifier, exception, entity.templateIdentifier()); @@ -140,12 +141,6 @@ private void handleUpdate(Entity entity, String connectorIdentifier) { } } - /// Handles the Update properties action for an entity. - /// - /// If the entity exists, only its properties are patched. If the entity does - /// not exist, it is created with only its properties. - /// - /// @param entity the entity to Update properties for private void handleUpdateProperties(Entity entity, String connectorIdentifier) { EntityPatch propertiesPatch = new EntityPatch(entity.name(), entity.properties(), List.of()); @@ -153,16 +148,16 @@ private void handleUpdateProperties(Entity entity, String connectorIdentifier) { if (entityService.entityExists(entity.templateIdentifier(), entity.identifier())) { entityService.patchEntity(entity.templateIdentifier(), entity.identifier(), propertiesPatch); - meterRegistry.counter(IngestionMetricConstants.ENTITY_PATCH_TOTAL, "template", + incrementCounter(IngestionMetricConstants.ENTITY_PATCH_TOTAL, "template", entity.templateIdentifier(), "connector", connectorIdentifier, "action", - "UPDATE_PROPERTIES").increment(); - meterRegistry.counter(IngestionMetricConstants.ENTITY_UPDATED_TOTAL, "template", - entity.templateIdentifier(), "connector", connectorIdentifier).increment(); + "UPDATE_PROPERTIES"); + incrementCounter(IngestionMetricConstants.ENTITY_UPDATED_TOTAL, "template", + entity.templateIdentifier(), "connector", connectorIdentifier); } else { entityService.createEntity(new Entity(null, entity.templateIdentifier(), entity.name(), entity.identifier(), entity.properties(), List.of())); - meterRegistry.counter(IngestionMetricConstants.ENTITY_CREATED_TOTAL, "template", - entity.templateIdentifier(), "connector", connectorIdentifier).increment(); + incrementCounter(IngestionMetricConstants.ENTITY_CREATED_TOTAL, "template", + entity.templateIdentifier(), "connector", connectorIdentifier); } } catch (RuntimeException exception) { recordEntityFailureMetrics(connectorIdentifier, exception, entity.templateIdentifier()); @@ -170,12 +165,6 @@ private void handleUpdateProperties(Entity entity, String connectorIdentifier) { } } - /// Handles the Update relations action for an entity. If the entity exists, - /// only its relations are patched. The entity must exist in order to patch the - /// relations. - /// - /// @param entity the entity to update relations for - /// @throws EntityNotFoundException when the entity does not exist private void handleUpdateRelations(Entity entity, String connectorIdentifier) { try { if (!entityService.entityExists(entity.templateIdentifier(), entity.identifier())) { @@ -184,25 +173,22 @@ private void handleUpdateRelations(Entity entity, String connectorIdentifier) { EntityPatch relationsPatch = new EntityPatch(null, List.of(), entity.relations()); entityService.patchEntity(entity.templateIdentifier(), entity.identifier(), relationsPatch); - meterRegistry.counter(IngestionMetricConstants.ENTITY_PATCH_TOTAL, "template", + incrementCounter(IngestionMetricConstants.ENTITY_PATCH_TOTAL, "template", entity.templateIdentifier(), "connector", connectorIdentifier, "action", - "UPDATE_RELATIONS").increment(); - meterRegistry.counter(IngestionMetricConstants.ENTITY_UPDATED_TOTAL, "template", - entity.templateIdentifier(), "connector", connectorIdentifier).increment(); + "UPDATE_RELATIONS"); + incrementCounter(IngestionMetricConstants.ENTITY_UPDATED_TOTAL, "template", + entity.templateIdentifier(), "connector", connectorIdentifier); } catch (RuntimeException exception) { recordEntityFailureMetrics(connectorIdentifier, exception, entity.templateIdentifier()); throw exception; } } - /// Handles the delete action for an entity. - /// - /// @param entity the entity to delete private void handleDelete(Entity entity, String connectorIdentifier) { try { entityService.deleteEntity(entity.templateIdentifier(), entity.identifier()); - meterRegistry.counter(IngestionMetricConstants.ENTITY_DELETED_TOTAL, "template", - entity.templateIdentifier(), "connector", connectorIdentifier).increment(); + incrementCounter(IngestionMetricConstants.ENTITY_DELETED_TOTAL, "template", + entity.templateIdentifier(), "connector", connectorIdentifier); log.debug("Deleted entity: {} for template: {}", entity.identifier(), entity.templateIdentifier()); } catch (RuntimeException exception) { @@ -217,26 +203,33 @@ private void recordEntityFailureMetrics(String connectorIdentifier, RuntimeExcep private void recordEntityFailureMetrics(String connectorIdentifier, RuntimeException exception, String templateIdentifier) { - meterRegistry.counter(IngestionMetricConstants.ENTITY_VALIDATION_ERRORS_TOTAL, "template", + incrementCounter(IngestionMetricConstants.ENTITY_VALIDATION_ERRORS_TOTAL, "template", templateIdentifier, "connector", connectorIdentifier, "reason", - exception.getClass().getSimpleName()).increment(); - meterRegistry.counter(IngestionMetricConstants.FUNCTIONAL_VALIDATION_FAILED_TOTAL, "connector", + exception.getClass().getSimpleName()); + incrementCounter(IngestionMetricConstants.FUNCTIONAL_VALIDATION_FAILED_TOTAL, "connector", connectorIdentifier, "template", templateIdentifier, "reason", - exception.getClass().getSimpleName()).increment(); + exception.getClass().getSimpleName()); if (exception instanceof EntityNotFoundException) { - meterRegistry.counter(IngestionMetricConstants.ENTITY_NOT_FOUND_TOTAL, "template", - templateIdentifier, "connector", connectorIdentifier, "action", "ingestion").increment(); + incrementCounter(IngestionMetricConstants.ENTITY_NOT_FOUND_TOTAL, "template", + templateIdentifier, "connector", connectorIdentifier, "action", "ingestion"); } if (exception instanceof EntityAlreadyExistsException) { - meterRegistry.counter(IngestionMetricConstants.ENTITY_CONFLICT_TOTAL, "template", - templateIdentifier, "connector", connectorIdentifier, "reason", "already_exists") - .increment(); + incrementCounter(IngestionMetricConstants.ENTITY_CONFLICT_TOTAL, "template", + templateIdentifier, "connector", connectorIdentifier, "reason", "already_exists"); } if (exception instanceof EntityValidationException) { - meterRegistry.counter(IngestionMetricConstants.ENTITY_VALIDATION_ERRORS_TOTAL, "template", + incrementCounter(IngestionMetricConstants.ENTITY_VALIDATION_ERRORS_TOTAL, "template", templateIdentifier, "connector", connectorIdentifier, "reason", - exception.getClass().getSimpleName()).increment(); + exception.getClass().getSimpleName()); } } + + private void incrementCounter(String metricName, String... attributes) { + IngestionObservabilitySupport.incrementCounter(meter, metricName, attributes); + } + + private void recordHistogram(String metricName, long value, String... attributes) { + IngestionObservabilitySupport.recordHistogram(meter, metricName, value, attributes); + } } 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 7432107f..0e8da4c2 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 @@ -3,6 +3,7 @@ import java.util.List; import java.util.Map; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException; @@ -12,9 +13,11 @@ import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy; import com.decathlon.idp_core.infrastructure.adapters.ingestion.configuration.IngestionMetricConstants; import com.decathlon.idp_core.infrastructure.adapters.ingestion.exception.WebhookSecurityException; +import com.decathlon.idp_core.infrastructure.adapters.ingestion.metrics.IngestionObservabilitySupport; import com.decathlon.idp_core.infrastructure.adapters.ingestion.security.WebhookRequestAuthenticator; -import io.micrometer.core.instrument.MeterRegistry; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.Meter; import io.opentelemetry.api.trace.Span; import lombok.extern.slf4j.Slf4j; @@ -30,11 +33,12 @@ public class SecurityProcessor { private static final String RESULT_ERROR = "ERROR"; private final List strategies; - private final MeterRegistry meterRegistry; + private final Meter meter; - public SecurityProcessor(List strategies, MeterRegistry meterRegistry) { + public SecurityProcessor(List strategies, + @Qualifier("securityMeter") Meter meter) { this.strategies = List.copyOf(strategies); - this.meterRegistry = meterRegistry; + this.meter = meter; } public void validate(Map headers, byte[] rawPayload, @@ -45,9 +49,10 @@ public void validate(Map headers, byte[] rawPayload, ? WebhookSecurityType.NONE.name() : security.type().name(); - meterRegistry.counter(IngestionMetricConstants.SECURITY_REQUESTS_TOTAL, "connector", - webhookIdentifier, "security_type", securityType).increment(); - Span.current().setAttribute(SPAN_ATTRIBUTE_WEBHOOK_IDENTIFIER, webhookIdentifier); + incrementCounter(IngestionMetricConstants.SECURITY_REQUESTS_TOTAL, "connector", + webhookIdentifier, "security_type", securityType); + IngestionObservabilitySupport.setSpanAttribute(Span.current(), + SPAN_ATTRIBUTE_WEBHOOK_IDENTIFIER, webhookIdentifier); if (security == null || security.type() == WebhookSecurityType.NONE) { recordSecurityResult(webhookIdentifier, securityType, RESULT_SUCCESS); @@ -72,17 +77,21 @@ public void validate(Map headers, byte[] rawPayload, } catch (RuntimeException exception) { String result = isSecurityRejected(exception) ? RESULT_REJECTED : RESULT_ERROR; recordSecurityResult(webhookIdentifier, securityType, result); - meterRegistry.counter(IngestionMetricConstants.SECURITY_VALIDATION_ERRORS_TOTAL, "connector", - webhookIdentifier, "security_type", securityType, "reason", classifyFailure(exception)) - .increment(); + incrementCounter(IngestionMetricConstants.SECURITY_VALIDATION_ERRORS_TOTAL, "connector", + webhookIdentifier, "security_type", securityType, "reason", classifyFailure(exception)); throw exception; } } private void recordSecurityResult(String webhookIdentifier, String securityType, String result) { - meterRegistry.counter(IngestionMetricConstants.SECURITY_RESULT, "connector", webhookIdentifier, - "security_type", securityType, "result", result).increment(); - Span.current().setAttribute(SPAN_ATTRIBUTE_SECURITY_RESULT, result); + incrementCounter(IngestionMetricConstants.SECURITY_RESULT, "connector", webhookIdentifier, + "security_type", securityType, "result", result); + IngestionObservabilitySupport.setSpanAttribute(Span.current(), SPAN_ATTRIBUTE_SECURITY_RESULT, + result); + } + + private void incrementCounter(String metricName, String... attributes) { + IngestionObservabilitySupport.incrementCounter(meter, metricName, attributes); } private boolean isSecurityRejected(RuntimeException exception) { @@ -94,5 +103,4 @@ private String classifyFailure(RuntimeException exception) { String reason = exception.getClass().getSimpleName(); return reason.isBlank() ? "unknown" : reason; } - } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/decoder/DecodingProcessor.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/decoder/DecodingProcessor.java index cb010f08..dd131ac8 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/decoder/DecodingProcessor.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/decoder/DecodingProcessor.java @@ -14,11 +14,15 @@ import java.util.zip.GZIPInputStream; import java.util.zip.ZipException; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import com.decathlon.idp_core.infrastructure.adapters.ingestion.configuration.IngestionMetricConstants; import com.decathlon.idp_core.infrastructure.adapters.ingestion.exception.WebhookDecodingException; +import io.opentelemetry.api.metrics.Meter; + /// Processor responsible for decoding webhook payloads based on HTTP `Content-Encoding` headers. /// /// Supported Encodings: @@ -36,20 +40,17 @@ public class DecodingProcessor { private static final int SANITIZED_HEADER_MAX_LENGTH = 128; private static final int DECOMPRESSION_BUFFER_SIZE = 8192; - // Maximum input payload size (before decompression) — aligns with - // PayloadValidationProcessor - // Prevents unbounded String→byte[] or byte[]→String conversions private static final long MAX_INPUT_PAYLOAD_BYTES = 10L * 1024 * 1024; // 10MB private final Map decoders; - - // Max decompressed size — configurable via idp.ingestion.max-decompressed-bytes - // to prevent Zip Bomb attacks. + private final Meter meter; private final long maxDecompressedBytes; public DecodingProcessor( - @Value("${idp.ingestion.max-decompressed-bytes:10485760}") long maxDecompressedBytes) { + @Value("${idp.ingestion.max-decompressed-bytes:10485760}") long maxDecompressedBytes, + @Qualifier("decodingMeter") Meter meter) { this.maxDecompressedBytes = maxDecompressedBytes; + this.meter = meter; this.decoders = Map.of(CONTENT_ENCODING_IDENTITY, this::decodeIdentity, CONTENT_ENCODING_GZIP, this::decodeGzip); } @@ -60,39 +61,81 @@ public DecodingProcessor( public String decode(Object encodedPayload, Map headers) { String contentEncoding = extractContentEncodingHeader(headers); List encodingChain = parseEncodingChain(contentEncoding); + String connector = headers == null + ? "unknown" + : String.valueOf(headers.getOrDefault("connector", "unknown")); - if (encodingChain.isEmpty()) { - return payloadToString(encodedPayload); - } + try { + if (encodingChain.isEmpty()) { + String decoded = payloadToString(encodedPayload); + recordPayloadSizeMetric(connector, decoded.getBytes(StandardCharsets.UTF_8).length); + incrementDecodedMetric(connector, "identity"); + return decoded; + } - // Reject unsupported encodings explicitly — silent pass-through risks silent - // data corruption. - List unsupported = encodingChain.stream() - .filter(encoding -> !decoders.containsKey(encoding)).toList(); - if (!unsupported.isEmpty()) { - throw new WebhookDecodingException( - "Unsupported Content-Encoding: '" + sanitizeHeaderValue(contentEncoding) - + "'. Supported encodings: " + String.join(", ", decoders.keySet())); - } + // Reject unsupported encodings explicitly — silent pass-through risks silent + // data corruption. + List unsupported = encodingChain.stream() + .filter(encoding -> !decoders.containsKey(encoding)).toList(); + if (!unsupported.isEmpty()) { + String reason = "unsupported_encoding"; + incrementDecodeErrorMetric(connector, reason); + throw new WebhookDecodingException( + "Unsupported Content-Encoding: '" + sanitizeHeaderValue(contentEncoding) + + "'. Supported encodings: " + String.join(", ", decoders.keySet())); + } - byte[] decodedPayload = toByteArray(encodedPayload); - if (decodedPayload.length == 0 && encodingChain.contains(CONTENT_ENCODING_GZIP)) { - throw new WebhookDecodingException("Empty payload cannot be decoded as gzip"); - } + byte[] decodedPayload = toByteArray(encodedPayload); + if (decodedPayload.length == 0 && encodingChain.contains(CONTENT_ENCODING_GZIP)) { + incrementDecodeErrorMetric(connector, "empty_gzip_payload"); + throw new WebhookDecodingException("Empty payload cannot be decoded as gzip"); + } - try { - for (int i = encodingChain.size() - 1; i >= 0; i--) { - decodedPayload = decoders.get(encodingChain.get(i)).decode(decodedPayload); + try { + for (int i = encodingChain.size() - 1; i >= 0; i--) { + decodedPayload = decoders.get(encodingChain.get(i)).decode(decodedPayload); + } + String result = new String(decodedPayload, StandardCharsets.UTF_8); + recordPayloadSizeMetric(connector, decodedPayload.length); + incrementDecodedMetric(connector, String.join(",", encodingChain)); + return result; + } catch (ZipException e) { + incrementDecodeErrorMetric(connector, "corrupted_gzip_payload"); + throw new WebhookDecodingException("Corrupted or invalid compressed gzip stream", e); + } catch (IOException e) { + incrementDecodeErrorMetric(connector, "decompression_failure"); + throw new WebhookDecodingException( + "Failed to decompress payload for encoding: " + sanitizeHeaderValue(contentEncoding), + e); + } + } catch (RuntimeException exception) { + if (!(exception instanceof WebhookDecodingException)) { + incrementDecodeErrorMetric(connector, exception.getClass().getSimpleName()); } - return new String(decodedPayload, StandardCharsets.UTF_8); - } catch (ZipException e) { - throw new WebhookDecodingException("Corrupted or invalid compressed gzip stream", e); - } catch (IOException e) { - throw new WebhookDecodingException( - "Failed to decompress payload for encoding: " + sanitizeHeaderValue(contentEncoding), e); + throw exception; } } + private void recordPayloadSizeMetric(String connector, long payloadSizeBytes) { + meter.histogramBuilder(IngestionMetricConstants.PAYLOAD_SIZE_BYTES).ofLongs().build() + .record(payloadSizeBytes, io.opentelemetry.api.common.Attributes + .of(io.opentelemetry.api.common.AttributeKey.stringKey("connector"), connector)); + } + + private void incrementDecodedMetric(String connector, String encoding) { + meter.counterBuilder(IngestionMetricConstants.PAYLOAD_DECODED_TOTAL).build().add(1, + io.opentelemetry.api.common.Attributes.of( + io.opentelemetry.api.common.AttributeKey.stringKey("connector"), connector, + io.opentelemetry.api.common.AttributeKey.stringKey("encoding"), encoding)); + } + + private void incrementDecodeErrorMetric(String connector, String reason) { + meter.counterBuilder(IngestionMetricConstants.PAYLOAD_DECODE_ERRORS_TOTAL).build().add(1, + io.opentelemetry.api.common.Attributes.of( + io.opentelemetry.api.common.AttributeKey.stringKey("connector"), connector, + io.opentelemetry.api.common.AttributeKey.stringKey("reason"), reason)); + } + private String extractContentEncodingHeader(Map headers) { if (headers == null) return null; diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/DecodingProcessorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/DecodingProcessorTest.java index f5ec5ebc..56f5f206 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/DecodingProcessorTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/DecodingProcessorTest.java @@ -20,6 +20,10 @@ import com.decathlon.idp_core.infrastructure.adapters.ingestion.exception.WebhookDecodingException; import com.decathlon.idp_core.infrastructure.adapters.ingestion.processor.decoder.DecodingProcessor; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; + @DisplayName("DecodingProcessor unit tests") class DecodingProcessorTest { @@ -27,9 +31,13 @@ class DecodingProcessorTest { private static final byte[] CORRUPTED_GZIP_PAYLOAD = "not-a-gzip-stream" .getBytes(StandardCharsets.UTF_8); + private final Meter meter = OpenTelemetrySdk.builder() + .setMeterProvider(SdkMeterProvider.builder().build()).build() + .getMeter("com.decathlon.idp_core.tests"); + // Default processor with the production default limit (10 MB) private final DecodingProcessor decodingProcessor = new DecodingProcessor( - IngestionConstants.MAX_DECOMPRESSED_BYTES); + IngestionConstants.MAX_DECOMPRESSED_BYTES, meter); @ParameterizedTest(name = "[{index}]") @MethodSource("passThroughCases") @@ -103,7 +111,7 @@ void decode_throwsWebhookDecodingException_whenContentEncodingIsUnsupported( @DisplayName("Throws WebhookDecodingException when decompressed payload exceeds the configured size limit (Zip Bomb protection)") void decode_throwsWebhookDecodingException_whenDecompressedSizeExceedsLimit() throws Exception { // Processor with a very small limit of 10 bytes to trigger the zip bomb guard - DecodingProcessor restrictedProcessor = new DecodingProcessor(10L); + DecodingProcessor restrictedProcessor = new DecodingProcessor(10L, meter); byte[] gzipPayload = gzip("This payload will exceed the 10 bytes decompression limit"); WebhookDecodingException exception = assertThrows(WebhookDecodingException.class, diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/IngestionProcessorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/IngestionProcessorTest.java index 5aaf873b..ea56bd10 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/IngestionProcessorTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/IngestionProcessorTest.java @@ -41,7 +41,11 @@ import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.read.ListAppender; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; /// Unit tests for IngestionProcessor covering all mapping actions and edge cases. /// @@ -71,8 +75,14 @@ void setUp() { listAppender = new ListAppender<>(); listAppender.start(); logger.addAppender(listAppender); - ingestionProcessor = new IngestionProcessor(mappingEngine, entityService, - new SimpleMeterRegistry()); + + InMemoryMetricReader metricReader = InMemoryMetricReader.create(); + OpenTelemetry openTelemetry = OpenTelemetrySdk.builder() + .setMeterProvider(SdkMeterProvider.builder().registerMetricReader(metricReader).build()) + .build(); + Meter meter = openTelemetry.getMeter("com.decathlon.idp_core.tests"); + + ingestionProcessor = new IngestionProcessor(mappingEngine, entityService, meter); } private Entity createTestEntity(String templateId, String identifier) { From d55eb9496a6278a2972fa8d8114aa7a18c6be279 Mon Sep 17 00:00:00 2001 From: foukou19 Date: Thu, 15 Oct 2026 18:09:50 +0200 Subject: [PATCH 11/14] feat(config): centralize webhook metrics and tracing Signed-off-by: foukou19 --- .../IngestionMetricsConfiguration.java | 7 +- ...a => IngestionObservabilityConstants.java} | 22 ++-- .../IngestionObservabilitySupport.java | 17 ++- .../processor/IngestionProcessor.java | 105 +++++++++--------- .../processor/SecurityProcessor.java | 16 +-- .../processor/decoder/DecodingProcessor.java | 53 +++++---- src/main/resources/application.yml | 27 +++-- 7 files changed, 140 insertions(+), 107 deletions(-) rename src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/{IngestionMetricConstants.java => IngestionObservabilityConstants.java} (74%) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionMetricsConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionMetricsConfiguration.java index 0d5b7979..2c7f5ab2 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionMetricsConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionMetricsConfiguration.java @@ -11,17 +11,16 @@ public class IngestionMetricsConfiguration { @Bean("ingestionMeter") public Meter ingestionMeter(OpenTelemetry openTelemetry) { - return openTelemetry.getMeterProvider().meterBuilder("com.decathlon.idp_core.ingestion") - .build(); + return openTelemetry.getMeter("com.decathlon.idp_core.ingestion"); } @Bean("securityMeter") public Meter securityMeter(OpenTelemetry openTelemetry) { - return openTelemetry.getMeterProvider().meterBuilder("com.decathlon.idp_core.security").build(); + return openTelemetry.getMeter("com.decathlon.idp_core.security"); } @Bean("decodingMeter") public Meter decodingMeter(OpenTelemetry openTelemetry) { - return openTelemetry.getMeterProvider().meterBuilder("com.decathlon.idp_core.decoding").build(); + return openTelemetry.getMeter("com.decathlon.idp_core.decoding"); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionMetricConstants.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionObservabilityConstants.java similarity index 74% rename from src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionMetricConstants.java rename to src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionObservabilityConstants.java index a8f1261d..b245664d 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionMetricConstants.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionObservabilityConstants.java @@ -1,14 +1,6 @@ package com.decathlon.idp_core.infrastructure.adapters.ingestion.configuration; -/** - * Shared metric names for the ingestion workflow. - * - *

- * These values are intentionally centralized so the routing and processing - * layers can reuse the same business metric names without duplicating string - * literals across processors. - */ -public final class IngestionMetricConstants { +public final class IngestionObservabilityConstants { public static final String SECURITY_REQUESTS_TOTAL = "idp.webhook.security.requests.total"; public static final String SECURITY_RESULT = "idp.webhook.security.result"; @@ -34,6 +26,16 @@ public final class IngestionMetricConstants { public static final String ENTITY_NOT_FOUND_TOTAL = "idp.webhook.entity.notfound.total"; public static final String ENTITY_CONFLICT_TOTAL = "idp.webhook.entity.conflict.total"; - private IngestionMetricConstants() { + public static final String ATTRIBUTE_CONNECTOR = "connector"; + public static final String ATTRIBUTE_MAPPING_ID = "mapping_id"; + public static final String ATTRIBUTE_ACTION = "action"; + public static final String ATTRIBUTE_REASON = "reason"; + public static final String ATTRIBUTE_TEMPLATE = "template"; + public static final String ATTRIBUTE_RESULT = "result"; + public static final String ATTRIBUTE_SECURITY_TYPE = "security_type"; + + public static final String SPAN_ATTRIBUTE_WEBHOOK_IDENTIFIER = "idp.webhook.identifier"; + + private IngestionObservabilityConstants() { } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/metrics/IngestionObservabilitySupport.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/metrics/IngestionObservabilitySupport.java index 15f1cf14..d066654b 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/metrics/IngestionObservabilitySupport.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/metrics/IngestionObservabilitySupport.java @@ -1,22 +1,33 @@ package com.decathlon.idp_core.infrastructure.adapters.ingestion.metrics; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.LongCounter; import io.opentelemetry.api.metrics.LongHistogram; import io.opentelemetry.api.metrics.Meter; import io.opentelemetry.api.trace.Span; public final class IngestionObservabilitySupport { + private static final Map COUNTER_CACHE = new ConcurrentHashMap<>(); + private static final Map HISTOGRAM_CACHE = new ConcurrentHashMap<>(); + private IngestionObservabilitySupport() { } public static void incrementCounter(Meter meter, String metricName, String... attributes) { - meter.counterBuilder(metricName).build().add(1, toAttributes(attributes)); + LongCounter counter = COUNTER_CACHE.computeIfAbsent(metricName, + name -> meter.counterBuilder(name).build()); + counter.add(1, toAttributes(attributes)); } public static void recordHistogram(Meter meter, String metricName, long value, String... attributes) { - LongHistogram histogram = meter.histogramBuilder(metricName).ofLongs().build(); + LongHistogram histogram = HISTOGRAM_CACHE.computeIfAbsent(metricName, + name -> meter.histogramBuilder(name).ofLongs().build()); histogram.record(value, toAttributes(attributes)); } @@ -37,7 +48,7 @@ public static Attributes toAttributes(String... keyValues) { } var builder = Attributes.builder(); for (int i = 0; i < keyValues.length; i += 2) { - builder.put(keyValues[i], keyValues[i + 1]); + builder.put(AttributeKey.stringKey(keyValues[i]), keyValues[i + 1]); } return builder.build(); } 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 548dd7cc..d26c6639 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 @@ -1,5 +1,7 @@ package com.decathlon.idp_core.infrastructure.adapters.ingestion.processor; +import static com.decathlon.idp_core.infrastructure.adapters.ingestion.configuration.IngestionObservabilityConstants.*; + import java.util.List; import org.springframework.beans.factory.annotation.Qualifier; @@ -14,7 +16,7 @@ import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; import com.decathlon.idp_core.domain.port.MappingEnginePort; import com.decathlon.idp_core.domain.service.entity.EntityService; -import com.decathlon.idp_core.infrastructure.adapters.ingestion.configuration.IngestionMetricConstants; +import com.decathlon.idp_core.infrastructure.adapters.ingestion.configuration.IngestionObservabilityConstants; import com.decathlon.idp_core.infrastructure.adapters.ingestion.metrics.IngestionObservabilitySupport; import io.opentelemetry.api.metrics.Meter; @@ -25,8 +27,6 @@ @Slf4j public class IngestionProcessor { - private static final String SPAN_ATTRIBUTE_WEBHOOK_IDENTIFIER = "idp.webhook.identifier"; - private final MappingEnginePort mappingEngine; private final EntityService entityService; private final Meter meter; @@ -70,23 +70,24 @@ private void applyMapping(String payload, EntityDynamicMapping mapping, String action = mapping.action() == null ? "unknown" : mapping.action().name(); long startTimeNanos = System.nanoTime(); - incrementCounter(IngestionMetricConstants.MAPPING_APPLIED_TOTAL, - "connector", connectorIdentifier, "mapping_id", mappingId, "action", action); + incrementCounter(MAPPING_APPLIED_TOTAL, ATTRIBUTE_CONNECTOR, connectorIdentifier, ATTRIBUTE_MAPPING_ID, mappingId, ATTRIBUTE_ACTION, action); try { Entity entity = mappingEngine.mapToEntity(payload, mapping); if (entity == null) { - incrementCounter(IngestionMetricConstants.MAPPING_FILTERED_TOTAL, - "connector", connectorIdentifier, "mapping_id", mappingId, "reason", + incrementCounter(MAPPING_FILTERED_TOTAL, + ATTRIBUTE_CONNECTOR, connectorIdentifier, ATTRIBUTE_MAPPING_ID, mappingId, ATTRIBUTE_REASON, "filter_rejected"); - incrementCounter(IngestionMetricConstants.MAPPING_RESULT, - "connector", connectorIdentifier, "mapping_id", mappingId, "result", "FILTERED_OUT"); + incrementCounter(MAPPING_RESULT, + ATTRIBUTE_CONNECTOR, connectorIdentifier, ATTRIBUTE_MAPPING_ID, mappingId, ATTRIBUTE_RESULT, + "FILTERED_OUT"); return; } - incrementCounter(IngestionMetricConstants.MAPPING_RESULT, - "connector", connectorIdentifier, "mapping_id", mappingId, "result", "MATCHED"); + incrementCounter(MAPPING_RESULT, + ATTRIBUTE_CONNECTOR, connectorIdentifier, ATTRIBUTE_MAPPING_ID, mappingId, ATTRIBUTE_RESULT, + "MATCHED"); switch (mapping.action()) { case UPDATE_ENTITY -> handleUpdate(entity, connectorIdentifier); @@ -99,20 +100,20 @@ private void applyMapping(String payload, EntityDynamicMapping mapping, log.info("Successfully processed action {} for entity: {} under template: {}", mapping.action(), entity.identifier(), entity.templateIdentifier()); } catch (RuntimeException exception) { - incrementCounter(IngestionMetricConstants.MAPPING_ERRORS_TOTAL, - "connector", connectorIdentifier, "mapping_id", mappingId, + incrementCounter(MAPPING_ERRORS_TOTAL, + ATTRIBUTE_CONNECTOR, connectorIdentifier, ATTRIBUTE_MAPPING_ID, mappingId, "error_type", exception.getClass().getSimpleName()); - incrementCounter(IngestionMetricConstants.MAPPING_RESULT, - "connector", connectorIdentifier, "mapping_id", mappingId, "result", "ERROR"); - incrementCounter(IngestionMetricConstants.FUNCTIONAL_VALIDATION_FAILED_TOTAL, - "connector", connectorIdentifier, "mapping_id", mappingId, - "reason", exception.getClass().getSimpleName()); + incrementCounter(MAPPING_RESULT, + ATTRIBUTE_CONNECTOR, connectorIdentifier, ATTRIBUTE_MAPPING_ID, mappingId, ATTRIBUTE_RESULT, "ERROR"); + incrementCounter(IngestionObservabilityConstants.FUNCTIONAL_VALIDATION_FAILED_TOTAL, + ATTRIBUTE_CONNECTOR, connectorIdentifier, "mapping_id", mappingId, + ATTRIBUTE_REASON, exception.getClass().getSimpleName()); recordEntityFailureMetrics(connectorIdentifier, exception); throw exception; } finally { long durationMs = (System.nanoTime() - startTimeNanos) / 1_000_000L; - recordHistogram(IngestionMetricConstants.MAPPING_LATENCY_MS, durationMs, - "connector", connectorIdentifier, "mapping_id", mappingId); + recordHistogram(IngestionObservabilityConstants.MAPPING_LATENCY_MS, durationMs, + ATTRIBUTE_CONNECTOR, connectorIdentifier, ATTRIBUTE_MAPPING_ID, mappingId); } } @@ -123,17 +124,17 @@ private void handleUpdate(Entity entity, String connectorIdentifier) { entity.templateIdentifier()); entityService.patchEntity(entity.templateIdentifier(), entity.identifier(), EntityPatch.fromEntity(entity)); - incrementCounter(IngestionMetricConstants.ENTITY_PATCH_TOTAL, "template", - entity.templateIdentifier(), "connector", connectorIdentifier, "action", + incrementCounter(IngestionObservabilityConstants.ENTITY_PATCH_TOTAL, ATTRIBUTE_TEMPLATE, + entity.templateIdentifier(), ATTRIBUTE_CONNECTOR, connectorIdentifier, ATTRIBUTE_ACTION, "UPDATE_ENTITY"); - incrementCounter(IngestionMetricConstants.ENTITY_UPDATED_TOTAL, "template", - entity.templateIdentifier(), "connector", connectorIdentifier); + incrementCounter(IngestionObservabilityConstants.ENTITY_UPDATED_TOTAL, ATTRIBUTE_TEMPLATE, + entity.templateIdentifier(), ATTRIBUTE_CONNECTOR, connectorIdentifier); } else { log.debug("Creating entity {} for template: {}", entity.identifier(), entity.templateIdentifier()); entityService.createEntity(entity); - incrementCounter(IngestionMetricConstants.ENTITY_CREATED_TOTAL, "template", - entity.templateIdentifier(), "connector", connectorIdentifier); + incrementCounter(IngestionObservabilityConstants.ENTITY_CREATED_TOTAL, ATTRIBUTE_TEMPLATE, + entity.templateIdentifier(), ATTRIBUTE_CONNECTOR, connectorIdentifier); } } catch (RuntimeException exception) { recordEntityFailureMetrics(connectorIdentifier, exception, entity.templateIdentifier()); @@ -148,16 +149,16 @@ private void handleUpdateProperties(Entity entity, String connectorIdentifier) { if (entityService.entityExists(entity.templateIdentifier(), entity.identifier())) { entityService.patchEntity(entity.templateIdentifier(), entity.identifier(), propertiesPatch); - incrementCounter(IngestionMetricConstants.ENTITY_PATCH_TOTAL, "template", - entity.templateIdentifier(), "connector", connectorIdentifier, "action", + incrementCounter(IngestionObservabilityConstants.ENTITY_PATCH_TOTAL, ATTRIBUTE_TEMPLATE, + entity.templateIdentifier(), ATTRIBUTE_CONNECTOR, connectorIdentifier, ATTRIBUTE_ACTION, "UPDATE_PROPERTIES"); - incrementCounter(IngestionMetricConstants.ENTITY_UPDATED_TOTAL, "template", - entity.templateIdentifier(), "connector", connectorIdentifier); + incrementCounter(IngestionObservabilityConstants.ENTITY_UPDATED_TOTAL, ATTRIBUTE_TEMPLATE, + entity.templateIdentifier(), ATTRIBUTE_CONNECTOR, connectorIdentifier); } else { entityService.createEntity(new Entity(null, entity.templateIdentifier(), entity.name(), entity.identifier(), entity.properties(), List.of())); - incrementCounter(IngestionMetricConstants.ENTITY_CREATED_TOTAL, "template", - entity.templateIdentifier(), "connector", connectorIdentifier); + incrementCounter(IngestionObservabilityConstants.ENTITY_CREATED_TOTAL, ATTRIBUTE_TEMPLATE, + entity.templateIdentifier(), ATTRIBUTE_CONNECTOR, connectorIdentifier); } } catch (RuntimeException exception) { recordEntityFailureMetrics(connectorIdentifier, exception, entity.templateIdentifier()); @@ -173,11 +174,11 @@ private void handleUpdateRelations(Entity entity, String connectorIdentifier) { EntityPatch relationsPatch = new EntityPatch(null, List.of(), entity.relations()); entityService.patchEntity(entity.templateIdentifier(), entity.identifier(), relationsPatch); - incrementCounter(IngestionMetricConstants.ENTITY_PATCH_TOTAL, "template", - entity.templateIdentifier(), "connector", connectorIdentifier, "action", + incrementCounter(IngestionObservabilityConstants.ENTITY_PATCH_TOTAL, ATTRIBUTE_TEMPLATE, + entity.templateIdentifier(), ATTRIBUTE_CONNECTOR, connectorIdentifier, ATTRIBUTE_ACTION, "UPDATE_RELATIONS"); - incrementCounter(IngestionMetricConstants.ENTITY_UPDATED_TOTAL, "template", - entity.templateIdentifier(), "connector", connectorIdentifier); + incrementCounter(IngestionObservabilityConstants.ENTITY_UPDATED_TOTAL, ATTRIBUTE_TEMPLATE, + entity.templateIdentifier(), ATTRIBUTE_CONNECTOR, connectorIdentifier); } catch (RuntimeException exception) { recordEntityFailureMetrics(connectorIdentifier, exception, entity.templateIdentifier()); throw exception; @@ -187,8 +188,8 @@ private void handleUpdateRelations(Entity entity, String connectorIdentifier) { private void handleDelete(Entity entity, String connectorIdentifier) { try { entityService.deleteEntity(entity.templateIdentifier(), entity.identifier()); - incrementCounter(IngestionMetricConstants.ENTITY_DELETED_TOTAL, "template", - entity.templateIdentifier(), "connector", connectorIdentifier); + incrementCounter(IngestionObservabilityConstants.ENTITY_DELETED_TOTAL, ATTRIBUTE_TEMPLATE, + entity.templateIdentifier(), ATTRIBUTE_CONNECTOR, connectorIdentifier); log.debug("Deleted entity: {} for template: {}", entity.identifier(), entity.templateIdentifier()); } catch (RuntimeException exception) { @@ -203,25 +204,27 @@ private void recordEntityFailureMetrics(String connectorIdentifier, RuntimeExcep private void recordEntityFailureMetrics(String connectorIdentifier, RuntimeException exception, String templateIdentifier) { - incrementCounter(IngestionMetricConstants.ENTITY_VALIDATION_ERRORS_TOTAL, "template", - templateIdentifier, "connector", connectorIdentifier, "reason", - exception.getClass().getSimpleName()); - incrementCounter(IngestionMetricConstants.FUNCTIONAL_VALIDATION_FAILED_TOTAL, "connector", - connectorIdentifier, "template", templateIdentifier, "reason", - exception.getClass().getSimpleName()); + incrementCounter(IngestionObservabilityConstants.ENTITY_VALIDATION_ERRORS_TOTAL, + ATTRIBUTE_TEMPLATE, templateIdentifier, ATTRIBUTE_CONNECTOR, connectorIdentifier, + ATTRIBUTE_REASON, exception.getClass().getSimpleName()); + incrementCounter(IngestionObservabilityConstants.FUNCTIONAL_VALIDATION_FAILED_TOTAL, + ATTRIBUTE_CONNECTOR, connectorIdentifier, ATTRIBUTE_TEMPLATE, templateIdentifier, + ATTRIBUTE_REASON, exception.getClass().getSimpleName()); if (exception instanceof EntityNotFoundException) { - incrementCounter(IngestionMetricConstants.ENTITY_NOT_FOUND_TOTAL, "template", - templateIdentifier, "connector", connectorIdentifier, "action", "ingestion"); + incrementCounter(IngestionObservabilityConstants.ENTITY_NOT_FOUND_TOTAL, ATTRIBUTE_TEMPLATE, + templateIdentifier, ATTRIBUTE_CONNECTOR, connectorIdentifier, ATTRIBUTE_ACTION, + "ingestion"); } if (exception instanceof EntityAlreadyExistsException) { - incrementCounter(IngestionMetricConstants.ENTITY_CONFLICT_TOTAL, "template", - templateIdentifier, "connector", connectorIdentifier, "reason", "already_exists"); + incrementCounter(IngestionObservabilityConstants.ENTITY_CONFLICT_TOTAL, ATTRIBUTE_TEMPLATE, + templateIdentifier, ATTRIBUTE_CONNECTOR, connectorIdentifier, ATTRIBUTE_REASON, + "already_exists"); } if (exception instanceof EntityValidationException) { - incrementCounter(IngestionMetricConstants.ENTITY_VALIDATION_ERRORS_TOTAL, "template", - templateIdentifier, "connector", connectorIdentifier, "reason", - exception.getClass().getSimpleName()); + incrementCounter(IngestionObservabilityConstants.ENTITY_VALIDATION_ERRORS_TOTAL, + ATTRIBUTE_TEMPLATE, templateIdentifier, ATTRIBUTE_CONNECTOR, connectorIdentifier, + ATTRIBUTE_REASON, exception.getClass().getSimpleName()); } } 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 0e8da4c2..80b9d218 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 @@ -1,5 +1,7 @@ package com.decathlon.idp_core.infrastructure.adapters.ingestion.processor; +import static com.decathlon.idp_core.infrastructure.adapters.ingestion.configuration.IngestionObservabilityConstants.*; + import java.util.List; import java.util.Map; @@ -11,12 +13,11 @@ import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy; -import com.decathlon.idp_core.infrastructure.adapters.ingestion.configuration.IngestionMetricConstants; +import com.decathlon.idp_core.infrastructure.adapters.ingestion.configuration.IngestionObservabilityConstants; import com.decathlon.idp_core.infrastructure.adapters.ingestion.exception.WebhookSecurityException; import com.decathlon.idp_core.infrastructure.adapters.ingestion.metrics.IngestionObservabilitySupport; import com.decathlon.idp_core.infrastructure.adapters.ingestion.security.WebhookRequestAuthenticator; -import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.metrics.Meter; import io.opentelemetry.api.trace.Span; import lombok.extern.slf4j.Slf4j; @@ -49,7 +50,7 @@ public void validate(Map headers, byte[] rawPayload, ? WebhookSecurityType.NONE.name() : security.type().name(); - incrementCounter(IngestionMetricConstants.SECURITY_REQUESTS_TOTAL, "connector", + incrementCounter(IngestionObservabilityConstants.SECURITY_REQUESTS_TOTAL, ATTRIBUTE_CONNECTOR, webhookIdentifier, "security_type", securityType); IngestionObservabilitySupport.setSpanAttribute(Span.current(), SPAN_ATTRIBUTE_WEBHOOK_IDENTIFIER, webhookIdentifier); @@ -77,15 +78,16 @@ public void validate(Map headers, byte[] rawPayload, } catch (RuntimeException exception) { String result = isSecurityRejected(exception) ? RESULT_REJECTED : RESULT_ERROR; recordSecurityResult(webhookIdentifier, securityType, result); - incrementCounter(IngestionMetricConstants.SECURITY_VALIDATION_ERRORS_TOTAL, "connector", - webhookIdentifier, "security_type", securityType, "reason", classifyFailure(exception)); + incrementCounter(IngestionObservabilityConstants.SECURITY_VALIDATION_ERRORS_TOTAL, + ATTRIBUTE_CONNECTOR, webhookIdentifier, ATTRIBUTE_SECURITY_TYPE, securityType, + ATTRIBUTE_REASON, classifyFailure(exception)); throw exception; } } private void recordSecurityResult(String webhookIdentifier, String securityType, String result) { - incrementCounter(IngestionMetricConstants.SECURITY_RESULT, "connector", webhookIdentifier, - "security_type", securityType, "result", result); + incrementCounter(IngestionObservabilityConstants.SECURITY_RESULT, ATTRIBUTE_CONNECTOR, + webhookIdentifier, ATTRIBUTE_SECURITY_TYPE, securityType, "result", result); IngestionObservabilitySupport.setSpanAttribute(Span.current(), SPAN_ATTRIBUTE_SECURITY_RESULT, result); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/decoder/DecodingProcessor.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/decoder/DecodingProcessor.java index dd131ac8..23d73bfe 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/decoder/DecodingProcessor.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/decoder/DecodingProcessor.java @@ -1,6 +1,7 @@ package com.decathlon.idp_core.infrastructure.adapters.ingestion.processor.decoder; import static com.decathlon.idp_core.infrastructure.adapters.ingestion.configuration.IngestionConstants.*; +import static com.decathlon.idp_core.infrastructure.adapters.ingestion.configuration.IngestionObservabilityConstants.*; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -18,7 +19,6 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import com.decathlon.idp_core.infrastructure.adapters.ingestion.configuration.IngestionMetricConstants; import com.decathlon.idp_core.infrastructure.adapters.ingestion.exception.WebhookDecodingException; import io.opentelemetry.api.metrics.Meter; @@ -91,23 +91,8 @@ public String decode(Object encodedPayload, Map headers) { throw new WebhookDecodingException("Empty payload cannot be decoded as gzip"); } - try { - for (int i = encodingChain.size() - 1; i >= 0; i--) { - decodedPayload = decoders.get(encodingChain.get(i)).decode(decodedPayload); - } - String result = new String(decodedPayload, StandardCharsets.UTF_8); - recordPayloadSizeMetric(connector, decodedPayload.length); - incrementDecodedMetric(connector, String.join(",", encodingChain)); - return result; - } catch (ZipException e) { - incrementDecodeErrorMetric(connector, "corrupted_gzip_payload"); - throw new WebhookDecodingException("Corrupted or invalid compressed gzip stream", e); - } catch (IOException e) { - incrementDecodeErrorMetric(connector, "decompression_failure"); - throw new WebhookDecodingException( - "Failed to decompress payload for encoding: " + sanitizeHeaderValue(contentEncoding), - e); - } + return decodePayloadWithEncodingChain(decodedPayload, encodingChain, contentEncoding, + connector); } catch (RuntimeException exception) { if (!(exception instanceof WebhookDecodingException)) { incrementDecodeErrorMetric(connector, exception.getClass().getSimpleName()); @@ -116,23 +101,43 @@ public String decode(Object encodedPayload, Map headers) { } } + private String decodePayloadWithEncodingChain(byte[] decodedPayload, List encodingChain, + String contentEncoding, String connector) { + try { + for (int i = encodingChain.size() - 1; i >= 0; i--) { + decodedPayload = decoders.get(encodingChain.get(i)).decode(decodedPayload); + } + String result = new String(decodedPayload, StandardCharsets.UTF_8); + recordPayloadSizeMetric(connector, decodedPayload.length); + incrementDecodedMetric(connector, String.join(",", encodingChain)); + return result; + } catch (ZipException e) { + incrementDecodeErrorMetric(connector, "corrupted_gzip_payload"); + throw new WebhookDecodingException("Corrupted or invalid compressed gzip stream", e); + } catch (IOException e) { + incrementDecodeErrorMetric(connector, "decompression_failure"); + throw new WebhookDecodingException( + "Failed to decompress payload for encoding: " + sanitizeHeaderValue(contentEncoding), e); + } + } + private void recordPayloadSizeMetric(String connector, long payloadSizeBytes) { - meter.histogramBuilder(IngestionMetricConstants.PAYLOAD_SIZE_BYTES).ofLongs().build() - .record(payloadSizeBytes, io.opentelemetry.api.common.Attributes + meter.histogramBuilder(PAYLOAD_SIZE_BYTES).ofLongs().build().record(payloadSizeBytes, + io.opentelemetry.api.common.Attributes .of(io.opentelemetry.api.common.AttributeKey.stringKey("connector"), connector)); } private void incrementDecodedMetric(String connector, String encoding) { - meter.counterBuilder(IngestionMetricConstants.PAYLOAD_DECODED_TOTAL).build().add(1, + meter.counterBuilder(PAYLOAD_DECODED_TOTAL).build().add(1, io.opentelemetry.api.common.Attributes.of( - io.opentelemetry.api.common.AttributeKey.stringKey("connector"), connector, + io.opentelemetry.api.common.AttributeKey.stringKey(ATTRIBUTE_CONNECTOR), connector, io.opentelemetry.api.common.AttributeKey.stringKey("encoding"), encoding)); } private void incrementDecodeErrorMetric(String connector, String reason) { - meter.counterBuilder(IngestionMetricConstants.PAYLOAD_DECODE_ERRORS_TOTAL).build().add(1, + meter.counterBuilder(PAYLOAD_DECODE_ERRORS_TOTAL).build().add(1, io.opentelemetry.api.common.Attributes.of( - io.opentelemetry.api.common.AttributeKey.stringKey("connector"), connector, + io.opentelemetry.api.common.AttributeKey.stringKey(ATTRIBUTE_CONNECTOR), connector, io.opentelemetry.api.common.AttributeKey.stringKey("reason"), reason)); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 83ac46c2..5d6e6aff 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -176,9 +176,18 @@ logging: org.springframework.context.support: WARN # Suppresses a specific repetitive bean registration delegate log line. org.springframework.context.support.PostProcessorRegistrationDelegate: ERROR + io.opentelemetry: DEBUG + io.opentelemetry.sdk: DEBUG + io.opentelemetry.exporter: DEBUG + io.opentelemetry.exporter.otlp: DEBUG + com.decathlon.idp_core.infrastructure.adapters.ingestion: DEBUG management: + metrics: + export: + otlp: + enabled: ${OTEL_MICROMETER_EXPORT_ENABLED:false} tracing: sampling: # Fraction of traces kept (1.0 = 100%). Standard OTel variable. @@ -186,15 +195,18 @@ management: export: enabled: ${OTEL_TRACING_ENABLED:false} - # Trace OTLP export lives under management.opentelemetry.tracing.export.otlp.* (see below). otlp: metrics: export: - # 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_METRICS_PROTOCOL:${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf}} + url: ${OTEL_EXPORTER_OTLP_ENDPOINT} + enabled: true + protocol: ${OTEL_EXPORTER_OTLP_METRICS_PROTOCOL:${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf}} opentelemetry: + metrics: + export: + otlp: + endpoint: ${OTEL_EXPORTER_OTLP_METRICS_ENDPOINT:${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/metrics} + transport: ${OTEL_EXPORTER_OTLP_METRICS_TRANSPORT:${OTEL_EXPORTER_OTLP_PROTOCOL:http}} tracing: export: otlp: @@ -202,8 +214,6 @@ management: 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:${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: deployment.environment: ${OTEL_ENV:local} service.namespace: ${OTEL_SERVICE_NAMESPACE:idp-core} @@ -238,7 +248,8 @@ management: # Avoids leaking internal infrastructure info to unauthenticated callers. show-details: when_authorized - camel: + metrics: + enabled: true opentelemetry2: enabled: true From 7d67e935627889e2c4c9e3352763e3f75f0d8e3f Mon Sep 17 00:00:00 2001 From: foukou19 Date: Fri, 16 Oct 2026 17:02:35 +0200 Subject: [PATCH 12/14] feat(config): update webhook metrics Signed-off-by: foukou19 --- pom.xml | 18 ++-- .../CamelTracingConfiguration.java | 39 +++++++++ .../configuration/OtelLoggingDebugConfig.java | 41 +++++++++ .../IngestionObservabilitySupport.java | 4 + src/main/resources/application.yml | 86 ++++++++++--------- src/test/resources/application-test.yml | 11 ++- 6 files changed, 148 insertions(+), 51 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/CamelTracingConfiguration.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/OtelLoggingDebugConfig.java diff --git a/pom.xml b/pom.xml index 46dfb6c3..538c508a 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,14 @@ + + + io.opentelemetry.instrumentation + opentelemetry-instrumentation-bom + 2.32.0 + pom + import + io.netty netty-bom @@ -338,9 +346,10 @@ camel-rest-starter + - org.springframework.boot - spring-boot-starter-opentelemetry + io.opentelemetry.instrumentation + opentelemetry-spring-boot-starter @@ -348,11 +357,6 @@ camel-opentelemetry2-starter - - org.apache.camel.springboot - camel-micrometer-starter - - io.opentelemetry opentelemetry-api diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/CamelTracingConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/CamelTracingConfiguration.java new file mode 100644 index 00000000..44f9d903 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/CamelTracingConfiguration.java @@ -0,0 +1,39 @@ +package com.decathlon.idp_core.infrastructure.adapters.ingestion.configuration; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.propagation.ContextPropagators; + +/// Exposes the auto-configured OpenTelemetry tracer to Camel. +/// +/// `camel-opentelemetry2` looks for a `Tracer` and `ContextPropagators` bean first, then falls back +/// to `GlobalOpenTelemetry`. The official OpenTelemetry starter does not register the global +/// instance, so without these beans Camel gets a no-op tracer and produces no route spans. +/// No SDK is created here: both beans delegate to the instance built by the starter. +/// +/// Active only with the official OpenTelemetry starter. Spring Boot's own OpenTelemetry starter +/// already defines a `Tracer` and `ContextPropagators`; redefining them there creates a bean cycle. +@Configuration +@ConditionalOnClass(name = "io.opentelemetry.instrumentation.spring.autoconfigure.OpenTelemetryAutoConfiguration") +public class CamelTracingConfiguration { + + /// Instrumentation scope used for spans created by Camel routes. + static final String CAMEL_INSTRUMENTATION_SCOPE = "camel"; + + /// Tracer backed by the starter SDK, resolved by Camel through its registry. + @Bean + Tracer camelTracer(OpenTelemetry openTelemetry) { + return openTelemetry.getTracer(CAMEL_INSTRUMENTATION_SCOPE); + } + + /// Propagators of the starter SDK, used by Camel to inject and extract trace + /// context. + @Bean + ContextPropagators camelContextPropagators(OpenTelemetry openTelemetry) { + return openTelemetry.getPropagators(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/OtelLoggingDebugConfig.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/OtelLoggingDebugConfig.java new file mode 100644 index 00000000..bd9f0539 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/OtelLoggingDebugConfig.java @@ -0,0 +1,41 @@ +package com.decathlon.idp_core.infrastructure.adapters.ingestion.configuration; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import io.opentelemetry.api.OpenTelemetry; + +/// Inspects the auto-configured telemetry instance without constructing a provider. +@Component +public final class OtelLoggingDebugConfig { + + private static final Logger log = LoggerFactory.getLogger(OtelLoggingDebugConfig.class); + private static final String INSTRUMENTATION_SCOPE = "com.decathlon.idp_core.debug"; + private final OpenTelemetry openTelemetry; + + /// Uses the instance created by the OpenTelemetry starter. + public OtelLoggingDebugConfig(OpenTelemetry openTelemetry) { + this.openTelemetry = openTelemetry; + } + + /// Reports a no-op meter without claiming successful delivery to a backend. + @EventListener(ApplicationReadyEvent.class) + public void debugOtelMeterInitialization() { + var meter = openTelemetry.getMeter(INSTRUMENTATION_SCOPE); + log.info("[OTEL SDK CHECK] OpenTelemetry: {}; MeterProvider: {}; Meter: {}", + openTelemetry.getClass().getName(), openTelemetry.getMeterProvider().getClass().getName(), + meter.getClass().getName()); + // The API fallback is not public; compare runtime types instead of SDK internal + // names. + if (meter.getClass().equals(OpenTelemetry.noop().getMeter(INSTRUMENTATION_SCOPE).getClass())) { + log.warn( + "[OTEL SDK CHECK] No-op Meter: custom metrics are not recorded. Check SDK autoconfiguration and OTEL_SDK_DISABLED."); + } else { + log.info( + "[OTEL SDK CHECK] Meter is not the API no-op. Exporter delivery must still be verified."); + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/metrics/IngestionObservabilitySupport.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/metrics/IngestionObservabilitySupport.java index d066654b..380df142 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/metrics/IngestionObservabilitySupport.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/metrics/IngestionObservabilitySupport.java @@ -1,5 +1,7 @@ package com.decathlon.idp_core.infrastructure.adapters.ingestion.metrics; +import static reactor.netty.http.HttpConnectionLiveness.log; + import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -19,6 +21,8 @@ private IngestionObservabilitySupport() { } public static void incrementCounter(Meter meter, String metricName, String... attributes) { + log.info(">>> [OTEL DEBUG] Increment counter: " + metricName + " with attributes: " + + java.util.Arrays.toString(attributes)); LongCounter counter = COUNTER_CACHE.computeIfAbsent(metricName, name -> meter.counterBuilder(name).build()); counter.add(1, toAttributes(attributes)); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5d6e6aff..f92de190 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,4 +1,3 @@ ---- spring: web: resources: @@ -6,7 +5,6 @@ spring: add-mappings: false cors: # Exact origins — set via SPRING_WEB_CORS_ALLOWED_ORIGINS env var (comma-separated). - # Use for known, fixed origins. allowed-origins: ${SPRING_WEB_CORS_ALLOWED_ORIGINS:} # Pattern-based origins — set via SPRING_WEB_CORS_ALLOWED_ORIGIN_PATTERNS env var (comma-separated). allowed-origin-patterns: ${SPRING_WEB_CORS_ALLOWED_ORIGIN_PATTERNS:} @@ -176,60 +174,68 @@ logging: org.springframework.context.support: WARN # Suppresses a specific repetitive bean registration delegate log line. org.springframework.context.support.PostProcessorRegistrationDelegate: ERROR - io.opentelemetry: DEBUG io.opentelemetry.sdk: DEBUG - io.opentelemetry.exporter: DEBUG io.opentelemetry.exporter.otlp: DEBUG com.decathlon.idp_core.infrastructure.adapters.ingestion: DEBUG + io.opentelemetry: TRACE + io.opentelemetry.sdk.metrics: TRACE + # Affiche la création et l'envoi des Spans OTel + io.opentelemetry.sdk.trace: TRACE + # Affiche l'interception des requêtes par Camel OpenTelemetry + org.apache.camel.opentelemetry: TRACE + io.opentelemetry.exporter: TRACE + io.opentelemetry.exporter.sender.okhttp: TRACE + khttp3: TRACE +# OpenTelemetry Spring Boot Starter Configuration +otel: + service: + name: ${OTEL_SERVICE_NAME:internal-developer-platform} + resource: + attributes: + deployment.environment: ${OTEL_ENV:local} + service.namespace: ${OTEL_SERVICE_NAMESPACE:idp-core} -management: metrics: + exporter: ${OTEL_METRICS_EXPORTER:otlp} + + metric: export: - otlp: - enabled: ${OTEL_MICROMETER_EXPORT_ENABLED:false} - tracing: - sampling: - # Fraction of traces kept (1.0 = 100%). Standard OTel variable. - probability: ${OTEL_TRACES_SAMPLER_ARG:0.1} - export: - enabled: ${OTEL_TRACING_ENABLED:false} + interval: ${OTEL_METRIC_EXPORT_INTERVAL:60000} - otlp: - metrics: - export: - url: ${OTEL_EXPORTER_OTLP_ENDPOINT} - enabled: true + traces: + exporter: ${OTEL_TRACES_EXPORTER:otlp} + sampler: ${OTEL_TRACES_SAMPLER:always_on} + + logs: + exporter: ${OTEL_LOGS_EXPORTER:none} + + exporter: + otlp: + endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318} + protocol: ${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf} + metrics: + endpoint: ${OTEL_EXPORTER_OTLP_METRICS_ENDPOINT:${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/metrics} protocol: ${OTEL_EXPORTER_OTLP_METRICS_PROTOCOL:${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf}} - opentelemetry: - metrics: - export: - otlp: - endpoint: ${OTEL_EXPORTER_OTLP_METRICS_ENDPOINT:${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/metrics} - transport: ${OTEL_EXPORTER_OTLP_METRICS_TRANSPORT:${OTEL_EXPORTER_OTLP_PROTOCOL:http}} - tracing: - export: - otlp: - # 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:${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} + traces: + endpoint: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/traces} + protocol: ${OTEL_EXPORTER_OTLP_TRACES_PROTOCOL:${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf}} + instrumentation: + micrometer: + enabled: false + spring-web: + enabled: true + spring-webmvc: + enabled: true +management: endpoints: web: exposure: - # Explicitly restrict exposed actuator endpoints to health only. - # Prevents accidental exposure of env, beans, configprops, etc. include: health endpoint: health: probes: - # Enables liveness (/actuator/health/liveness) and - # readiness (/actuator/health/readiness) probes. enabled: true group: liveness: @@ -249,7 +255,5 @@ management: show-details: when_authorized camel: - metrics: - enabled: true opentelemetry2: enabled: true diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 22564216..301761f9 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -60,10 +60,15 @@ app: hmac-env-value: test-github-secret jwks-env-key: JWKS_URI jwks-env-value: https://auth.example.com/.well-known/jwks.json +otel: + traces: + exporter: none + metrics: + exporter: none + logs: + exporter: none + management: - # Tests must not create or export traces. - tracing: - enabled: false endpoint: health: probes: From ec92e1df5fe6770271c8f21c12703a23ec758b10 Mon Sep 17 00:00:00 2001 From: foukou19 Date: Mon, 19 Oct 2026 11:35:30 +0200 Subject: [PATCH 13/14] feat(config): update camel tracing configuration Signed-off-by: foukou19 --- .../configuration/CamelTracingConfiguration.java | 8 ++++---- src/main/resources/application.yml | 11 +++++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/CamelTracingConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/CamelTracingConfiguration.java index 44f9d903..da5837a0 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/CamelTracingConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/CamelTracingConfiguration.java @@ -1,6 +1,6 @@ package com.decathlon.idp_core.infrastructure.adapters.ingestion.configuration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -15,10 +15,8 @@ /// instance, so without these beans Camel gets a no-op tracer and produces no route spans. /// No SDK is created here: both beans delegate to the instance built by the starter. /// -/// Active only with the official OpenTelemetry starter. Spring Boot's own OpenTelemetry starter -/// already defines a `Tracer` and `ContextPropagators`; redefining them there creates a bean cycle. +/// Active when a bean of type `OpenTelemetry` is present. @Configuration -@ConditionalOnClass(name = "io.opentelemetry.instrumentation.spring.autoconfigure.OpenTelemetryAutoConfiguration") public class CamelTracingConfiguration { /// Instrumentation scope used for spans created by Camel routes. @@ -26,6 +24,7 @@ public class CamelTracingConfiguration { /// Tracer backed by the starter SDK, resolved by Camel through its registry. @Bean + @ConditionalOnBean(OpenTelemetry.class) Tracer camelTracer(OpenTelemetry openTelemetry) { return openTelemetry.getTracer(CAMEL_INSTRUMENTATION_SCOPE); } @@ -33,6 +32,7 @@ Tracer camelTracer(OpenTelemetry openTelemetry) { /// Propagators of the starter SDK, used by Camel to inject and extract trace /// context. @Bean + @ConditionalOnBean(OpenTelemetry.class) ContextPropagators camelContextPropagators(OpenTelemetry openTelemetry) { return openTelemetry.getPropagators(); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f92de190..b838961a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -167,6 +167,9 @@ idp: allowed-jwks-hosts: ${ALLOWED_JWKS_HOSTS:} logging: + pattern: + # Keep trace/span ids in logs with the OpenTelemetry starter. + correlation: "[%32X{traceId:-}-%16X{spanId:-}] " level: # Application log level, defaults to INFO. Override per environment via LOG_LEVEL env var. com.decathlon: ${LOG_LEVEL:INFO} @@ -221,12 +224,20 @@ otel: endpoint: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/traces} protocol: ${OTEL_EXPORTER_OTLP_TRACES_PROTOCOL:${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf}} instrumentation: + common: + logging: + # Spring log patterns use traceId/spanId while OTel defaults to trace_id/span_id. + trace-id-key: traceId + span-id-key: spanId micrometer: enabled: false spring-web: enabled: true spring-webmvc: enabled: true + # OTEL_INSTRUMENTATION_JDBC_ENABLED=true to add database spans. + jdbc: + enabled: ${OTEL_INSTRUMENTATION_JDBC_ENABLED:false} management: endpoints: From 7059a37df9ff4fd1de88f404f432b2e5913d2020 Mon Sep 17 00:00:00 2001 From: foukou19 Date: Mon, 19 Oct 2026 15:24:51 +0200 Subject: [PATCH 14/14] feat(config): update CamelTracingConfiguration Signed-off-by: foukou19 --- docs/src/deployment/observability.md | 123 ++++++++++++------ .../CamelTracingConfiguration.java | 44 ++++--- .../processor/IngestionProcessor.java | 3 +- .../GenericInboundEventRouteBuilder.java | 67 +++++++++- src/main/resources/application.yml | 8 ++ 5 files changed, 180 insertions(+), 65 deletions(-) diff --git a/docs/src/deployment/observability.md b/docs/src/deployment/observability.md index e0d179e4..aa71a4e3 100644 --- a/docs/src/deployment/observability.md +++ b/docs/src/deployment/observability.md @@ -28,48 +28,83 @@ The app exposes health endpoints and uses OTLP export for traces and metrics whe ## Metrics -### Metrics exported by Spring Boot and Micrometer - -The application exposes the standard Micrometer metrics and OTLP export can be enabled through environment variables. - -```yaml -management: - tracing: - sampling: - 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} +### Native OpenTelemetry metrics + +The application uses `io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter` to configure the SDK, metric readers, and OTLP exporters. You do not need application-defined `OpenTelemetry` or `SdkMeterProvider` beans. The ingestion processors use the auto-configured instance. + +```yaml title="application.yml" +otel: + metrics: + exporter: ${OTEL_METRICS_EXPORTER:otlp} + exporter: + otlp: + endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318} + protocol: ${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf} ``` -### Custom business metrics +The previous Spring Boot starter exported Micrometer metrics. Its SDK configuration could attach an existing meter provider, but did not create one for these native instruments. Setting `management.opentelemetry.metrics.export.otlp.*` did not activate collection. A `DefaultMeter` therefore discarded custom measurements even while traces worked. -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. +`camel-opentelemetry2-starter` provides Camel tracing, not automatic base route metrics. This correction restores the existing custom metrics; it does not add Camel base metrics or export Micrometer registries. -Examples: +### Diagnose missing metrics + +1. Restart the application and inspect `[OTEL SDK CHECK]`. `DefaultMeter` means no-op collection; `SdkMeter` confirms a native SDK meter, not successful delivery to Datadog. +2. Temporarily set `OTEL_METRICS_EXPORTER=console,otlp` and `OTEL_METRIC_EXPORT_INTERVAL=5000`. The starter includes the logging exporter. Console and OTLP exporters can coexist without a manually created reader. +3. Send a webhook, then wait for the export interval. Look for `idp.webhook.security.requests.total` or `idp.webhook.mapping.applied.total`. A filtered mapping still records attempts and filtered results, but does not create an entity. +4. If console metrics appear but Datadog receives nothing, check exporter errors, collector metrics routing, authentication, and Datadog filters. Configure required request headers with `OTEL_EXPORTER_OTLP_METRICS_HEADERS`; resource attributes are not HTTP headers. +5. Restore `OTEL_METRICS_EXPORTER=otlp` and the normal interval after diagnosis. Console metrics can contain operational identifiers and generate large logs. + +> [!WARNING] +> On platforms that allocate CPU only during requests, background export can pause between requests. Check CPU allocation and instance lifetime if collection works but periodic export is delayed. + +For HTTP/protobuf, set `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` to the full collector URL, including `/v1/metrics`. Inside a container, `localhost` points to the application container. Use the collector service name, or `host.docker.internal` if the collector runs on your macOS host through Docker Desktop. The repository's Docker Compose file does not start a collector. + +### Verify the export path -- `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}` +Run the focused tests without a collector or Datadog credentials: -These metrics reflect the real security decision made by the application and should not duplicate the standard Camel `route.*` metrics. +```bash +mvn -Dspring.profiles.active=test -Dtest=OpenTelemetryMetricsAutoConfigurationTest test +``` + +The tests distinguish these checks: + +- **SDK collection**: an in-memory exporter receives a custom counter and histogram with the expected measurements. A separate test checks the warning when you disable the SDK. +- **OTLP HTTP transport**: the real exporter sends a `protobuf` request to a local server on an ephemeral port. The test checks the method, path, content type, configured header, and encoded metric, connector, and service identifiers. An HTTP 200 response completes the export; an HTTP 400 response reports failure. This check does not decode numeric points or exercise periodic scheduling. +- **Datadog delivery**: these tests do not contact your deployed collector or Datadog. After deployment, send a webhook and confirm reception at the collector, then in Datadog. Check that the collector enables an OTLP receiver and a metrics pipeline connected to the Datadog exporter, or that your Datadog Agent enables OTLP metrics ingestion. A successful HTTP response alone does not prove downstream delivery. + +> [!WARNING] +> Do not use `SdkMeterProvider.forceFlush().isSuccess()` as proof of HTTP acceptance. In this SDK version, the reader can complete the flush successfully even when the exporter receives HTTP 400. The transport tests observe the actual exporter result; in deployment, check exporter errors and collector reception. + +### Custom webhook ingestion metrics + +The application emits custom OpenTelemetry metrics in the webhook ingestion pipeline to capture business decisions and failures that are not exposed by the standard Camel route telemetry. + +The following metrics are produced by the ingestion, security and decoding processors: + +| Metric name | Attributes | Meaning | +|--------------------------------------------------|-----------------------------------------|------------------------------------------------------------------------------| +| `idp.webhook.security.requests.total` | `connector`, `security_type` | Number of security checks executed for a connector. | +| `idp.webhook.security.result` | `connector`, `security_type`, `result` | Result of the security decision (`allowed`, `rejected`, `error`). | +| `idp.webhook.security.validation.errors.total` | `connector`, `security_type`, `reason` | Security validation failures and malformed configuration errors. | +| `idp.webhook.payload.decoded.total` | `connector`, `encoding` | Successful payload decodes for each encoding step. | +| `idp.webhook.payload.decode.errors.total` | `connector`, `reason` | Payload decode failures, including unsupported or corrupted encodings. | +| `idp.webhook.payload.size.bytes` | `connector` | Histogram of decoded payload size in bytes. | +| `idp.webhook.mapping.applied.total` | `connector`, `mapping_id`, `action` | Mapping execution attempts. | +| `idp.webhook.mapping.filtered.total` | `connector`, `mapping_id`, `reason` | Mapping results rejected by the filter stage. | +| `idp.webhook.mapping.result` | `connector`, `mapping_id`, `result` | Mapping outcome (`MATCHED`, `FILTERED_OUT`, `ERROR`). | +| `idp.webhook.mapping.errors.total` | `connector`, `mapping_id`, `error_type` | Mapping exceptions and runtime failures. | +| `idp.webhook.mapping.latency.ms` | `connector`, `mapping_id` | Mapping duration histogram in milliseconds. | +| `idp.webhook.entity.created.total` | `template`, `connector` | Number of entities created during ingestion. | +| `idp.webhook.entity.updated.total` | `template`, `connector` | Number of entities updated during ingestion. | +| `idp.webhook.entity.deleted.total` | `template`, `connector` | Number of entities deleted during ingestion. | +| `idp.webhook.entity.patch.total` | `template`, `connector`, `action` | Number of patch operations applied. | +| `idp.webhook.entity.validation.errors.total` | `template`, `connector`, `reason` | Entity validation and business-rule failures. | +| `idp.webhook.entity.notfound.total` | `template`, `connector`, `action` | Missing entity operations during ingestion. | +| `idp.webhook.entity.conflict.total` | `template`, `connector`, `reason` | Conflict scenarios such as duplicate entities. | +| `idp.webhook.functional.validation.failed.total` | `connector`, `template`, `reason` | Functional validation failures captured after a mapping or entity operation. | + +These metric names are centralized in `IngestionObservabilityConstants` and are intentionally aligned with the connector, template, action, and reason dimensions used across the webhook processing pipeline. ## Logging @@ -85,19 +120,23 @@ OpenTelemetry tracing is enabled through Spring Boot configuration. The applicat | Variable | Description | Default | | --- | --- | --- | -| `OTEL_TRACING_ENABLED` | Enables tracing export | `false` | -| `OTEL_METRICS_EXPORT_ENABLED` | Enables OTLP metrics export | `false` | +| `OTEL_TRACES_EXPORTER` | Trace exporter; `none` disables export | `otlp` (`none` locally) | +| `OTEL_METRICS_EXPORTER` | Metric exporters; `console,otlp` for diagnosis | `otlp` | +| `OTEL_METRIC_EXPORT_INTERVAL` | Metric export interval in milliseconds | `60000` | +| `OTEL_LOGS_EXPORTER` | Log exporter | `none` | | `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_PROTOCOL` | OTLP protocol, `http/protobuf` or `grpc` | `http/protobuf` | +| `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | Trace-specific protocol override | `OTEL_EXPORTER_OTLP_PROTOCOL` | | `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL` | Metrics-specific transport override | `OTEL_EXPORTER_OTLP_PROTOCOL` | | `OTEL_RESOURCE_ATTRIBUTES` | Resource metadata injected by the environment | - | The signal-specific variables win over the generic fallback, so local development and hosted deployments can both work without hard-coded vendor configuration. +Replace the old `OTEL_TRACING_ENABLED` and `OTEL_METRICS_EXPORT_ENABLED` flags with exporter selection (`otlp` or `none`). Replace `*_TRANSPORT=http` with `*_PROTOCOL=http/protobuf`. The official starter reads `otel.*`, not the former `management.opentelemetry.*` export settings. The test profile disables network export for all signals. + ### Important trace attributes The application sets trace attributes for the webhook pipeline, including the connector identifier and the security decision. @@ -119,7 +158,9 @@ The app exposes health endpoints used by Docker and Kubernetes. | `/actuator/health/liveness` | Liveness probe | | `/actuator/health/readiness` | Readiness probe | -## Next steps +--- + +## Next Steps - **[Configuration](configuration.md)** - Environment and Spring Boot settings - **[Docker](docker.md)** - Docker deployment and OTLP examples diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/CamelTracingConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/CamelTracingConfiguration.java index da5837a0..65295fd6 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/CamelTracingConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/CamelTracingConfiguration.java @@ -1,39 +1,49 @@ package com.decathlon.idp_core.infrastructure.adapters.ingestion.configuration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.apache.camel.CamelContext; +import org.apache.camel.spring.boot.CamelContextConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.api.trace.Tracer; import io.opentelemetry.context.propagation.ContextPropagators; +import lombok.extern.slf4j.Slf4j; -/// Exposes the auto-configured OpenTelemetry tracer to Camel. -/// -/// `camel-opentelemetry2` looks for a `Tracer` and `ContextPropagators` bean first, then falls back -/// to `GlobalOpenTelemetry`. The official OpenTelemetry starter does not register the global -/// instance, so without these beans Camel gets a no-op tracer and produces no route spans. -/// No SDK is created here: both beans delegate to the instance built by the starter. -/// -/// Active when a bean of type `OpenTelemetry` is present. @Configuration +@Slf4j public class CamelTracingConfiguration { - /// Instrumentation scope used for spans created by Camel routes. static final String CAMEL_INSTRUMENTATION_SCOPE = "camel"; - /// Tracer backed by the starter SDK, resolved by Camel through its registry. @Bean - @ConditionalOnBean(OpenTelemetry.class) - Tracer camelTracer(OpenTelemetry openTelemetry) { + public Tracer camelTracer(OpenTelemetry openTelemetry) { return openTelemetry.getTracer(CAMEL_INSTRUMENTATION_SCOPE); } - /// Propagators of the starter SDK, used by Camel to inject and extract trace - /// context. @Bean - @ConditionalOnBean(OpenTelemetry.class) - ContextPropagators camelContextPropagators(OpenTelemetry openTelemetry) { + public ContextPropagators camelContextPropagators(OpenTelemetry openTelemetry) { return openTelemetry.getPropagators(); } + + @Bean + public CamelContextConfiguration camelTracingBridge(Tracer camelTracer, + ContextPropagators camelContextPropagators) { + return new CamelContextConfiguration() { + @Override + public void beforeApplicationStart(CamelContext camelContext) { + // Expose Spring SDK beans directly in Camel registry before route startup. + camelContext.getRegistry().bind("camelTracer", camelTracer); + camelContext.getRegistry().bind("camelContextPropagators", camelContextPropagators); + log.info( + "[OTEL CAMEL BRIDGE] Bound Tracer={} and ContextPropagators={} into Camel registry", + camelTracer.getClass().getName(), camelContextPropagators.getClass().getName()); + } + + @Override + public void afterApplicationStart(CamelContext camelContext) { + // No-op + } + }; + } } 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 d26c6639..87113993 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 @@ -56,7 +56,8 @@ public void ingest(String payload, WebhookConnector webhookConnectorConfiguratio Span currentSpan = Span.current(); IngestionObservabilitySupport.setSpanAttribute(currentSpan, SPAN_ATTRIBUTE_WEBHOOK_IDENTIFIER, connectorIdentifier); - + log.info("span id: {}, trace_id: {}", currentSpan.getSpanContext().getSpanId(), + currentSpan.getSpanContext().getTraceId());// todo todelete log.info("Starting ingestion for webhook connector: {}", connectorIdentifier); webhookConnectorConfiguration.mappings() .forEach(mapping -> applyMapping(payload, mapping, webhookConnectorConfiguration)); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/route/GenericInboundEventRouteBuilder.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/route/GenericInboundEventRouteBuilder.java index df730761..3472ddf6 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/route/GenericInboundEventRouteBuilder.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/route/GenericInboundEventRouteBuilder.java @@ -2,9 +2,12 @@ import static com.decathlon.idp_core.infrastructure.adapters.ingestion.configuration.IngestionConstants.*; +import java.util.stream.Collectors; + import org.apache.camel.Exchange; import org.apache.camel.LoggingLevel; import org.apache.camel.builder.RouteBuilder; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import com.decathlon.idp_core.domain.exception.webhook.WebhookConfigurationMissingException; @@ -17,11 +20,12 @@ import com.decathlon.idp_core.infrastructure.adapters.ingestion.processor.SecurityProcessor; import com.decathlon.idp_core.infrastructure.adapters.ingestion.processor.decoder.DecodingProcessor; -import lombok.RequiredArgsConstructor; +import io.opentelemetry.api.trace.Span; +import lombok.extern.slf4j.Slf4j; /// Generic Camel Route pipeline handling inbound webhook fetching, status validation, and payload decoding. @Component -@RequiredArgsConstructor +@Slf4j public class GenericInboundEventRouteBuilder extends RouteBuilder { private final WebhookConnectorService webhookConnectorService; @@ -31,15 +35,35 @@ public class GenericInboundEventRouteBuilder extends RouteBuilder { private final PayloadValidationProcessor payloadValidationProcessor; private final WebhookExceptionRouteBuilder webhookExceptionRouteBuilder; + private final boolean tracingDiagnosticEnabled; + + public GenericInboundEventRouteBuilder(WebhookConnectorService webhookConnectorService, + DecodingProcessor decodingProcessor, SecurityProcessor securityProcessor, + IngestionProcessor ingestionProcessor, PayloadValidationProcessor payloadValidationProcessor, + WebhookExceptionRouteBuilder webhookExceptionRouteBuilder, + @Value("${app.ingestion.tracing-diagnostic-enabled:false}") boolean tracingDiagnosticEnabled) { + this.webhookConnectorService = webhookConnectorService; + this.decodingProcessor = decodingProcessor; + this.securityProcessor = securityProcessor; + this.ingestionProcessor = ingestionProcessor; + this.payloadValidationProcessor = payloadValidationProcessor; + this.webhookExceptionRouteBuilder = webhookExceptionRouteBuilder; + this.tracingDiagnosticEnabled = tracingDiagnosticEnabled; + log.info("GenericInboundEventRouteBuilder initialized with tracingDiagnosticEnabled={}", + tracingDiagnosticEnabled); + } + @Override public void configure() throws Exception { webhookExceptionRouteBuilder.configureExceptions(this); // Main Pipeline - from(DIRECT_PROCESS_EVENT).routeId(ROUTE_ID_WEBHOOK_PIPELINE).to(DIRECT_FETCH_CONFIGURATION) - .to(DIRECT_VALIDATE_ENABLED).process(payloadValidationProcessor::validate) - .to(DIRECT_VALIDATE_SECURITY).to(DIRECT_DECODE_PAYLOAD).to(DIRECT_INGEST_PAYLOAD) - .removeHeaders("*").setHeader(Exchange.HTTP_RESPONSE_CODE, constant(HTTP_CREATED)) + from(DIRECT_PROCESS_EVENT).routeId(ROUTE_ID_WEBHOOK_PIPELINE) + .process(exchange -> logTracingDiagnostics(exchange, "direct-process-event-entry")) + .to(DIRECT_FETCH_CONFIGURATION).to(DIRECT_VALIDATE_ENABLED) + .process(payloadValidationProcessor::validate).to(DIRECT_VALIDATE_SECURITY) + .to(DIRECT_DECODE_PAYLOAD).to(DIRECT_INGEST_PAYLOAD).removeHeaders("*") + .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(HTTP_CREATED)) .setHeader(Exchange.CONTENT_TYPE, constant(APPLICATION_JSON)) .setBody(constant(SUCCESS_WEBHOOK_EVENT_PROCESSED)); @@ -107,6 +131,7 @@ public void configure() throws Exception { from(DIRECT_INGEST_PAYLOAD).routeId(ROUTE_ID_INGEST_PAYLOAD) .log(LoggingLevel.DEBUG, "Ingesting payload for webhook ID: ${exchangeProperty.connectorIdentifier}") + .process(exchange -> logTracingDiagnostics(exchange, "direct-ingest-payload")) .process(exchange -> { String decodedPayload = exchange.getIn().getBody(String.class); WebhookConnector config = exchange.getProperty(WEBHOOK_CONFIG_PROPERTY, @@ -114,4 +139,34 @@ public void configure() throws Exception { ingestionProcessor.ingest(decodedPayload, config); }); } + + private void logTracingDiagnostics(Exchange exchange, String stage) { + if (!tracingDiagnosticEnabled || !log.isInfoEnabled()) { + return; + } + + Span currentSpan = Span.current(); + String traceId = currentSpan.getSpanContext().getTraceId(); + String spanId = currentSpan.getSpanContext().getSpanId(); + boolean validSpan = currentSpan.getSpanContext().isValid(); + + String interestingProperties = exchange.getProperties().entrySet().stream() + .filter(entry -> isTelemetryKey(entry.getKey())) + .map(entry -> entry.getKey() + "=" + + (entry.getValue() == null ? "null" : entry.getValue().getClass().getSimpleName())) + .sorted().collect(Collectors.joining(", ")); + + log.info( + "[OTEL CAMEL DIAG] stage={} routeId={} exchangeId={} spanValid={} traceId={} spanId={} traceparent={} b3={} telemetryProperties=[{}]", + stage, exchange.getFromRouteId(), exchange.getExchangeId(), validSpan, traceId, spanId, + exchange.getIn().getHeader("traceparent"), exchange.getIn().getHeader("b3"), + interestingProperties); + } + + private boolean isTelemetryKey(String key) { + String normalized = key == null ? "" : key.toLowerCase(); + return normalized.contains("telemetry") || normalized.contains("trace") + || normalized.contains("span") || normalized.contains("baggage") + || normalized.startsWith("camel"); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b838961a..f02b7a34 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -110,6 +110,8 @@ springdoc: app: ingestion: + # Enable temporary diagnostics to inspect Camel exchange telemetry context propagation. + tracing-diagnostic-enabled: ${APP_INGESTION_TRACING_DIAGNOSTIC_ENABLED:true} webhook: base-path: /webhooks identifier-path: /{webhookIdentifier} @@ -192,6 +194,7 @@ logging: # OpenTelemetry Spring Boot Starter Configuration otel: + register-global: true service: name: ${OTEL_SERVICE_NAME:internal-developer-platform} resource: @@ -209,6 +212,7 @@ otel: traces: exporter: ${OTEL_TRACES_EXPORTER:otlp} sampler: ${OTEL_TRACES_SAMPLER:always_on} + sampler-arg: ${OTEL_TRACES_SAMPLER_ARG:1.0} logs: exporter: ${OTEL_LOGS_EXPORTER:none} @@ -268,3 +272,7 @@ management: camel: opentelemetry2: enabled: true + # Keep core route spans enabled; processor spans depend on them for active scope propagation. + disable-core-processors: false + # Keep processor-level spans (setHeader, setBody, processors...) for ingestion diagnostics. + trace-processors: true