Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions apiml/src/main/java/org/zowe/apiml/filter/OtelRequestFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import org.zowe.apiml.product.opentelemetry.OtelRequestContext;
import org.zowe.apiml.security.common.error.ServiceNotAccessibleException;
import org.springframework.web.reactive.resource.NoResourceFoundException;
import reactor.core.publisher.Mono;
import reactor.core.publisher.SignalType;

import java.util.*;
import java.util.function.Function;
Expand Down Expand Up @@ -147,15 +150,32 @@ private Mono<Void> filterInternal(ServerWebExchange exchange, Function<ServerWeb
// define default values from request perspective. they could be overwritten then
setDefaults(exchange, otelContext);

// capture attempted service for error messages
var pathElements = exchange.getRequest().getPath().elements();
var attemptedService = pathElements.size() > 1 ? pathElements.get(1).value() : SERVICE_GATEWAY;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is path-element index 1 used to extract the service ID? This is the gateway filter — if we're inside APIML modulith, the path structure may differ (e.g., /gateway/api/v1/...). I would expect service ID extraction to be a shared helper, not a magic index. See how RoutingConfigurationErrorFilterFactory or EurekaUtils populate the service ID — there's likely something already there. (pavel)


return filter.apply(exchange)
// downstream chain: route matching → routing → service call
.doOnError(NoResourceFoundException.class, e -> {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two doOnError handlers with hardcoded status codes, error types, and error messages — these constants should be defined at the top of the class (or in OtelRequestContext) so they're discoverable and reusable. (nxhafa)

otelContext.statusCode(404);
otelContext.errorType("Service not onboarded");
otelContext.errorMessage("Service " + attemptedService + " is not registered in the API ML");

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error message is built by string concatenation on the hot path. Consider using a structured logger or at least a private constant for the prefix Service %s is not registered in the API ML. (ilkinabdullayev)

})
.doOnError(ServiceNotAccessibleException.class, e -> {
otelContext.statusCode(503);
otelContext.errorType("Service instance not available");
otelContext.errorMessage(Objects.toString(e.getMessage(), "No available instances"));

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Objects.toString(e.getMessage(), "No available instances") — defaulting to a generic string when the exception message is null loses the operationally-useful context. Should we instead re-raise or log a warning? The current behavior silently swallows the missing message. (pavel)

})
// in all cases (success / error) issue the log message
.doFinally(signalType -> otelContext.issue())
// update response codes
.then(Mono.fromRunnable(() -> Optional.ofNullable(exchange.getResponse())
.map(ServerHttpResponse::getStatusCode)
.map(HttpStatusCode::value)
.ifPresent(otelContext::responseCode)
));
.doFinally(signalType -> {
if (signalType == SignalType.ON_COMPLETE) {
Optional.ofNullable(exchange.getResponse())
.map(ServerHttpResponse::getStatusCode)
.map(HttpStatusCode::value)
.ifPresent(otelContext::responseCode);
}
otelContext.issue();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doFinally callback now mutates state conditionally on signalType == ON_COMPLETE. This couples the finally handler to the success path in a way that's easy to miss. Could be refactored to:

.doOnSuccess(t -> Optional.ofNullable(exchange.getResponse())
    .map(ServerHttpResponse::getStatusCode)
    .map(HttpStatusCode::value)
    .ifPresent(otelContext::responseCode))
.doFinally(signalType -> otelContext.issue());

This separates the success-handler and the issue-log-handler. (taban03)

});
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.reactive.resource.NoResourceFoundException;
import org.springframework.web.server.WebFilterChain;
import org.zowe.apiml.product.opentelemetry.OtelRequestContext;
import org.zowe.apiml.security.common.error.ServiceNotAccessibleException;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

Expand Down Expand Up @@ -149,4 +151,50 @@ void givenRequest_whenFailed_thenIssueLog() {
verify(otelContext, times(1)).issue();
}

@Test

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both new tests follow the same pattern — only the exception type and expected error message differ. Could be parametrized via @ParameterizedTest with @MethodSource providing (exception, expectedStatusCode, expectedErrorType, expectedErrorMessage). This would also make adding new error scenarios trivial. (pavel)

void givenUnknownService_whenNoResourceFound_thenSet404AndErrorType() {
var filter = new OtelRequestFilter();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test case: ServiceNotFoundException (when the service ID is known but not registered). The PR description mentions "unknown service ID" but the implementation only tests NoResourceFoundException. What's the distinction? (CarsonCook)

var request = MockServerHttpRequest.get("http://localhost/unknownservice/api/v1/data")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test path /unknownservice/api/v1/data — the path element at index 1 is unknownservice. But the actual behavior on the gateway sidecar is that the first path element is the service ID. I see pathElements.get(1).value() is used as the fallback. Where is pathElements.get(0) assumed to be? Test paths should mirror production routing structure. (plavjanik)

.localAddress(InetSocketAddress.createUnresolved("localhost", 10010)).build();
var exchange = MockServerWebExchange.from(request);
var otelContext = spy(OtelRequestContext.of(exchange));
exchange.getAttributes().put(OTEL_CONTEXT, otelContext);

var chain = (GatewayFilterChain) e -> Mono.error(
new NoResourceFoundException(exchange.getRequest().getURI().getPath()));

StepVerifier.create(filter.filter(exchange, chain))
.expectError(NoResourceFoundException.class)
.verify();

verify(otelContext, times(1)).statusCode(404);
verify(otelContext, times(1)).errorType("Service not onboarded");
verify(otelContext, times(1)).errorMessage("Service unknownservice is not registered in the API ML");
verify(otelContext, times(1)).issue();
}

@Test
void givenServiceNotAccessible_whenCalled_thenSet503AndErrorType() {
var filter = new OtelRequestFilter();

var request = MockServerHttpRequest.get("http://localhost/discoverable-service/api/v1/data")
.localAddress(InetSocketAddress.createUnresolved("localhost", 10010)).build();
var exchange = MockServerWebExchange.from(request);
var otelContext = spy(OtelRequestContext.of(exchange));
exchange.getAttributes().put(OTEL_CONTEXT, otelContext);

var chain = (GatewayFilterChain) e -> Mono.error(
new ServiceNotAccessibleException("Service discoverable-service has no available instances"));

StepVerifier.create(filter.filter(exchange, chain))
.expectError(ServiceNotAccessibleException.class)
.verify();

verify(otelContext, times(1)).statusCode(503);
verify(otelContext, times(1)).errorType("Service instance not available");
verify(otelContext, times(1)).errorMessage("Service discoverable-service has no available instances");
verify(otelContext, times(1)).issue();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ public final class OtelRequestContext {
private static final String OTEL_ATTRIBUTE_AUTH_ERROR_MESSAGE = "auth.error.message";
private static final String OTEL_ATTRIBUTE_USER_ID = "user.id";
private static final String OTEL_ATTRIBUTE_DISTRIBUTED_USER_ID = "user.distributed.id";
private static final String OTEL_ATTRIBUTE_STATUS_CODE = "http.response.status_code";

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two attributes for HTTP status code: service.response_code (existing) and http.response.status_code (new). The README says "complement" but in practice they're very close — I'm worried they'll be confused. Consider using a single attribute name for downstream-service status vs gateway-resolved status. (pavel)

private static final String OTEL_ATTRIBUTE_ERROR_TYPE = "error.type";
private static final String OTEL_ATTRIBUTE_ERROR_MESSAGE = "error.message";

private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

Expand Down Expand Up @@ -88,6 +91,18 @@ public OtelRequestContext responseCode(int status) {
return put(OTEL_ATTRIBUTE_RESPONSE_CODE, String.valueOf(status));
}

public OtelRequestContext statusCode(int status) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new setters don't follow the same naming conventions as the existing ones. Existing uses responseCode(int) and stores as service.response_code. New uses statusCode(int) and stores as http.response.status_code. The naming asymmetry is confusing. I'd suggest reusing responseCode (or httpStatusCode) and consolidating the storage key. (ilkinabdullayev)

return put(OTEL_ATTRIBUTE_STATUS_CODE, String.valueOf(status));
}

public OtelRequestContext errorType(String errorType) {
return put(OTEL_ATTRIBUTE_ERROR_TYPE, errorType);
}

public OtelRequestContext errorMessage(String errorMessage) {
return put(OTEL_ATTRIBUTE_ERROR_MESSAGE, errorMessage);
}

public OtelRequestContext serviceId(String serviceId) {
return put(OTEL_ATTRIBUTE_SERVICE_ID, StringUtils.lowerCase(serviceId));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,24 @@ void givenOtelContext_whenSetResponseCode_thenTransformToString() {
assertEquals("204", getValue("service.response_code"));
}

@Test
void givenOtelContext_whenSetStatusCode_thenTransformToString() {
OtelRequestContext.of(exchange).statusCode(503);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three new tests for statusCode, errorType, errorMessage — all follow the same pattern. Could be parametrized with the existing givenOtelContext_whenSetResponseCode_thenTransformToString test. (pavel)

assertEquals("503", getValue("http.response.status_code"));
}

@Test
void givenOtelContext_whenSetErrorType_thenStoreIt() {
OtelRequestContext.of(exchange).errorType("Service not onboarded");
assertEquals("Service not onboarded", getValue("error.type"));
}

@Test
void givenOtelContext_whenSetErrorMessage_thenStoreIt() {
OtelRequestContext.of(exchange).errorMessage("Service instance not available");
assertEquals("Service instance not available", getValue("error.message"));
}

@Test
void givenOtelContext_whenSetServiceId_thenStoreLowerCase() {
OtelRequestContext.of(exchange).serviceId("serviceID");
Expand Down
46 changes: 46 additions & 0 deletions otel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,49 @@ Note that `docker compose` cli arguments override the `command` value in the doc
## Local run for development

To run the docker containers locally with the same setup as used in the integration tests, just run `docker compose up` (optionally with `-d`), or the scripts in the [sh](sh) directory, and then start the APIML modulith with the OpenTelemetry enabled. The signals received and exported by the collector are saved to the [otel-golden](otel-golden) folder. The Golden Tester exits after timeout reporting the result of validation in the container console/log. The timeout can be set in the [docker-compose.yml](docker-compose.yml) file.

## HTTP Error Attributes

Starting with issue [#4705](https://github.com/zowe/api-layer/issues/4705), the Gateway's `OtelRequestFilter` emits OpenTelemetry log signals with the following attributes when a routed request results in an error:

| Attribute | Key | Type | Description |
|-----------|-----|------|-------------|
| HTTP response status code | `http.response.status_code` | int | The actual HTTP status code returned to the client (e.g., 404, 503) |
| Error type | `error.type` | string | A short machine-readable string identifying the error category |
Comment thread
balhar-jakub marked this conversation as resolved.
| Error message | `error.message` | string | A human-readable description of the error |

These attributes complement the existing [`service.response_code`] attribute — `http.response.status_code` captures the HTTP status sent to the client, while `service.response_code` reflects the HTTP status from the downstream service call.

### Error Scenarios

Two error paths produce OTel log signals:

1. **Unknown Service ID (404):** When a request targets a Service ID that is not registered in the Discovery Service, the Gateway returns a 404. The OTel signal includes:

- `http.response.status_code` = `404`
- `error.type` = `"Service not onboarded"`
- `error.message` = `"Service <serviceId> is not registered in the API ML"`

2. **Service Instances Down (503):** When a request targets a registered Service ID that has no available instances, the Gateway returns a 503. The OTel signal includes:

- `http.response.status_code` = `503`
- `error.type` = `"Service instance not available"`
- `error.message` = the exception message from `ServiceNotAccessibleException`

### Monitoring Use

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Prometheus alert examples use otel_log_count{error_type=...} — but the actual attribute name is error.type (with a dot). The PromQL syntax shown is incorrect. Should be error\.type or the attribute names should be normalized. (nxhafa)


Operators can use these attributes in observability dashboards and alerting rules. Example Prometheus / Alertmanager alert expressions:

```yaml
# Alert when an unknown service is requested (404)
- alert: UnknownServiceRequested
expr: increase(otel_log_count{error_type="Service not onboarded"}[5m]) > 0
annotations:
summary: "Requests targeting unknown service ID"

# Alert when a registered service has no available instances (503)
- alert: ServiceInstancesDown
expr: increase(otel_log_count{error_type="Service instance not available"}[5m]) > 0
annotations:
summary: "Service has no available instances"
```
Loading