Skip to content

Commit fbadcf4

Browse files
committed
extend: Add OpenTelemetry example in YAML (#2528)
- Added `apis.yaml` with OpenTelemetry plugin configuration for distributed tracing in Membrane. - Improved `OpenTelemetryInterceptor` logging with actionable advice for missing `jul-to-slf4j` JAR. - Updated tests in `MethodSetterTest` to enhance coverage for scalar coercion. - Refactored `MethodSetter` to improve scalar type coercion logic and exception clarity. - Simplified OpenTelemetry example in README; replaced XML example with YAML, added base instructions, and updated trace output.
1 parent 89397c8 commit fbadcf4

7 files changed

Lines changed: 152 additions & 109 deletions

File tree

annot/src/main/java/com/predic8/membrane/annot/yaml/MethodSetter.java

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,21 @@
1414

1515
package com.predic8.membrane.annot.yaml;
1616

17-
import com.fasterxml.jackson.databind.JsonNode;
18-
import com.predic8.membrane.annot.MCChildElement;
17+
import com.fasterxml.jackson.databind.*;
18+
import com.predic8.membrane.annot.*;
1919
import org.jetbrains.annotations.*;
2020

21-
import javax.lang.model.util.Types;
21+
import javax.lang.model.util.*;
2222
import java.lang.reflect.*;
23-
import java.util.Collection;
24-
import java.util.List;
25-
import java.util.Map;
23+
import java.util.*;
2624

27-
import static com.predic8.membrane.annot.yaml.GenericYamlParser.createAndPopulateNode;
28-
import static com.predic8.membrane.annot.yaml.GenericYamlParser.parseListIncludingStartEvent;
25+
import static com.predic8.membrane.annot.yaml.GenericYamlParser.*;
2926
import static com.predic8.membrane.annot.yaml.McYamlIntrospector.*;
30-
import static java.lang.Boolean.parseBoolean;
31-
import static java.lang.Integer.parseInt;
32-
import static java.lang.Long.parseLong;
33-
import static java.util.Locale.ROOT;
27+
import static java.lang.Boolean.*;
28+
import static java.lang.Double.*;
29+
import static java.lang.Integer.*;
30+
import static java.lang.Long.*;
31+
import static java.util.Locale.*;
3432

3533
public class MethodSetter {
3634

@@ -85,27 +83,53 @@ public <T> void setSetter(T instance, ParsingContext ctx, JsonNode node, String
8583
private Object resolveSetterValue(ParsingContext ctx, JsonNode node, String key) throws WrongEnumConstantException, ParsingException {
8684
Class<?> wanted = getParameterType();
8785

86+
// Collections / repeated elements
8887
List<Object> list = getObjectList(ctx, node, key, wanted);
8988
if (list != null) return list;
9089

90+
// Structured objects
91+
if (McYamlIntrospector.isStructured(setter)) {
92+
if (beanClass != null) return createAndPopulateNode(ctx.updateContext(key), beanClass, node);
93+
return createAndPopulateNode(ctx.updateContext(key), wanted, node);
94+
}
95+
96+
return coerceScalarOrReference(ctx, node, key, wanted);
97+
}
98+
99+
/**
100+
* Attempts to coerce a given JSON node into the desired scalar, enum, reference, or map type,
101+
* as specified by the provided target class.
102+
*
103+
* @param ctx The parsing context, providing access to type resolution and bean lookup mechanisms.
104+
* @param node The JSON node to be coerced into the desired type.
105+
* @param key The key corresponding to the JSON node, often used for error messages or map assignments.
106+
* @param wanted The target class specifying the type into which the node should be converted.
107+
* @return The coerced object, matching the desired type, derived from the input node.
108+
* @throws WrongEnumConstantException If the node value does not match any of the constants in the enum type.
109+
* @throws ParsingException If the provided type is unsupported for coercion or other unexpected issues arise.
110+
*/
111+
Object coerceScalarOrReference(ParsingContext ctx, JsonNode node, String key, Class<?> wanted) throws WrongEnumConstantException {
112+
// Scalars, enums, bean refs, "other attributes"
91113
if (wanted.isEnum()) return parseEnum(wanted, node);
92114
if (wanted.equals(String.class)) return node.asText();
93115

94-
if (wanted == Integer.TYPE || wanted == Integer.class) return parseInt(node.asText());
95-
if (wanted == Long.TYPE || wanted == Long.class) return parseLong(node.asText());
96-
if (wanted == Boolean.TYPE || wanted == Boolean.class) return parseBoolean(node.asText());
97-
if (wanted.equals(Map.class) && McYamlIntrospector.hasOtherAttributes(setter)) return Map.of(key, node.asText());
116+
if (wanted == int.class || wanted == Integer.class)
117+
return node.isInt() ? node.intValue() : parseInt(node.asText());
118+
if (wanted == long.class || wanted == Long.class)
119+
return node.isLong() || node.isInt() ? node.longValue() : parseLong(node.asText());
120+
if (wanted == double.class || wanted == Double.class)
121+
return node.isNumber() ? node.doubleValue() : parseDouble(node.asText());
122+
if (wanted == boolean.class || wanted == Boolean.class)
123+
return node.isBoolean() ? node.booleanValue() : parseBoolean(node.asText());
124+
if (wanted.equals(Map.class) && McYamlIntrospector.hasOtherAttributes(setter))
125+
return Map.of(key, node.asText());
98126

99127
if (node.isTextual() && isBeanReference(wanted)) {
100128
return resolveReference(ctx, node, key, wanted);
101129
}
102130

103-
if (McYamlIntrospector.isStructured(setter)) {
104-
if (beanClass != null) return createAndPopulateNode(ctx.updateContext(key), beanClass, node);
105-
return createAndPopulateNode(ctx.updateContext(key), wanted, node);
106-
}
107131
if (McYamlIntrospector.isReferenceAttribute(setter)) return ctx.registry().resolve(node.asText());
108-
throw new RuntimeException("Not implemented setter type " + wanted);
132+
throw new ParsingException("Unsupported setter type: %s for key '%s' with node type %s".formatted(wanted.getName(), key, node.getNodeType()), node);
109133
}
110134

111135
private @Nullable List<Object> getObjectList(ParsingContext ctx, JsonNode node, String key, Class<?> wanted) {
@@ -118,7 +142,7 @@ private Object resolveSetterValue(ParsingContext ctx, JsonNode node, String key)
118142
if (o == null) continue;
119143
if (!elemType.isAssignableFrom(o.getClass())) {
120144
throw new ParsingException("Value of type '%s' is not allowed in list '%s'. Expected '%s'."
121-
.formatted(McYamlIntrospector.getElementName(o.getClass()), key, elemType.getSimpleName()), node);
145+
.formatted(McYamlIntrospector.getElementName(o.getClass()), key, elemType.getSimpleName()), node);
122146
}
123147
}
124148
}

annot/src/test/java/com/predic8/membrane/annot/yaml/MethodSetterTest.java

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,20 @@
1414

1515
package com.predic8.membrane.annot.yaml;
1616

17-
import com.predic8.membrane.annot.MCChildElement;
18-
import com.predic8.membrane.annot.util.GrammarMock;
19-
import org.junit.jupiter.api.Test;
17+
import com.fasterxml.jackson.core.*;
18+
import com.fasterxml.jackson.databind.*;
19+
import com.predic8.membrane.annot.*;
20+
import com.predic8.membrane.annot.util.*;
21+
import org.junit.jupiter.api.*;
2022

21-
import static com.predic8.membrane.annot.yaml.MethodSetter.getMethodSetter;
23+
import static com.predic8.membrane.annot.yaml.MethodSetter.*;
2224
import static org.junit.jupiter.api.Assertions.*;
25+
import static org.junit.jupiter.api.Assertions.assertEquals;
2326

2427
class MethodSetterTest {
2528

29+
private static final ObjectMapper om = new ObjectMapper();
30+
2631
@SuppressWarnings("unused")
2732
static class A {
2833
public void setA1(B b) {}
@@ -44,25 +49,37 @@ public static class B {}
4449
public static class C {}
4550

4651
@Test
47-
public void dontUseMethodsWithoutChildElementAnnotation() {
52+
void dontUseMethodsWithoutChildElementAnnotation() {
4853
MethodSetter ms = getMethodSetter(new ParsingContext("foo", null,
4954
new GrammarMock().withGlobalElement("b", B.class)),
5055
A.class, "b");
5156
assertEquals("setA3", ms.getSetter().getName());
5257
}
5358

5459
@Test
55-
public void multiplePotentialSettersFound() {
60+
void multiplePotentialSettersFound() {
5661
assertThrowsExactly(RuntimeException.class, () -> getMethodSetter(new ParsingContext("foo", null,
5762
new GrammarMock().withGlobalElement("b", B.class)),
5863
A2.class, "b"));
5964
}
6065

6166
@Test
62-
public void noPotentialSetterFound() {
67+
void noPotentialSetterFound() {
6368
assertThrowsExactly(RuntimeException.class, () -> getMethodSetter(new ParsingContext("foo", null,
6469
new GrammarMock().withGlobalElement("c", C.class)),
6570
A2.class, "c"));
6671
}
6772

73+
@Test
74+
void foo() throws Exception {
75+
var ms = new MethodSetter(null, null);
76+
assertEquals(true, ms.coerceScalarOrReference(null, om.readTree("true"), null, boolean.class));
77+
assertEquals(true, ms.coerceScalarOrReference(null, om.readTree("true"), null, Boolean.class));
78+
assertEquals(1, ms.coerceScalarOrReference(null, om.readTree("1"), null, int.class));
79+
assertEquals(1.0, ms.coerceScalarOrReference(null, om.readTree("1"), null, double.class));
80+
var l = ms.coerceScalarOrReference(null, om.readTree("1"), null, long.class);
81+
assertInstanceOf(Long.class, l);
82+
assertEquals(1L, l);
83+
assertEquals(true, ms.coerceScalarOrReference(null, om.readTree("true"), null, boolean.class));
84+
}
6885
}

core/src/main/java/com/predic8/membrane/core/interceptor/opentelemetry/OpenTelemetryInterceptor.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ public void init() {
8181
SLF4JBridgeHandler.install();
8282
}
8383
} catch (Throwable t) {
84-
log.warn("jul-to-slf4j not available; OpenTelemetry logs may go to stderr.", t);
84+
log.warn("jul-to-slf4j is not available; OpenTelemetry logs may go to stderr. Add the jul-to-slf4j JAR to the lib folder if you want to avoid this.");
8585
}
8686

8787
otel = OpenTelemetryConfigurator.openTelemetry("Membrane", exporter, getSampleRate());
Lines changed: 40 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,98 +1,59 @@
11
# Tracing with OpenTelemetry
22

3-
Membrane offers support for tracing according to the [OpenTelemetry](https://opentelemetry.io/) specification.
3+
Membrane supports distributed tracing based on the [OpenTelemetry](https://opentelemetry.io/) specification.
44

5-
The usage of APIs can be observed with the OpenTelemetry plugin. Membrane collects data about the processes flowing
6-
through it and sends it to an OTLP endpoint, in this case, a jaeger backend.
5+
With the OpenTelemetry plugin, API traffic can be observed end to end. Membrane collects tracing data for requests flowing through the gateway and exports it to an OTLP endpoint. In this example, Jaeger is used as the backend.
76

8-
To instrument an API add the `opentelemetry` plugin to it.
7+
To instrument an API, add the `openTelemetry` plugin to the API configuration.
98

109
## Run the Example
1110

1211
1. Start Jaeger with:
1312
```dockerfile
14-
docker run -d --name jaeger -e COLLECTOR_OTLP_ENABLED=true -p 16686:16686 -p 4317:4317 -p 4318:4318 jaegertracing/all-in-one:latest
13+
docker run -it --name jaeger -e COLLECTOR_OTLP_ENABLED=true -p 16686:16686 -p 4317:4317 -p 4318:4318 jaegertracing/all-in-one:latest
1514
```
1615

17-
2. Run `membrane.cmd` or `./membrane.sh` to start Membrane.
16+
2. Start Membrane: `membrane.cmd` or `./membrane.sh`
1817

1918
3. Call the first endpoint in the telemetry chain:
2019

21-
`curl http://localhost:2000`.
20+
```bash
21+
curl http://localhost:2000
22+
```
2223

23-
4. You should see `Hello from a faked backend!` in your terminal.
24-
5. Open `localhost:16686` in the browser to access the Jaeger UI.
24+
4. You should see `Greetings from the backend!` in your terminal.
25+
5. Open the Jaeger UI in your browser: `http://localhost:16686`
2526
6. Select Membrane as the service and click on `Find Traces`.
26-
A span created by Membrane should be visible in [Jaeger UI](http://localhost:16686).
27-
![sample](resources/otel_sample.png)
28-
7. Examine the printed header fields on the console. You will see headers called `traceparent`, these denote which spans were involved in the request.
29-
30-
**HOW IT IS DONE**
31-
32-
Take a look at the `proxies.xml`.
33-
34-
```xml
35-
<router>
36-
37-
<transport>
38-
<ruleMatching />
39-
<logContext />
40-
<exchangeStore />
41-
<dispatching />
42-
<reverseProxying />
43-
<openTelemetry sampleRate="1.0"> <!--globally registers OpenTelemetry for every api-->
44-
<otlpExporter host="localhost" port="4317" transport="grpc"/>
45-
</openTelemetry>
46-
<userFeature />
47-
<internalRouting />
48-
<httpClient />
49-
</transport>
50-
51-
<api port="2000">
52-
<target url="http://localhost:2001" />
53-
</api>
54-
55-
<api port="2001" name="AccessControl">
56-
<target url="http://localhost:2002" />
57-
</api>
58-
59-
<api port="2002" name="Validation">
60-
<target url="http://localhost:3000" />
61-
</api>
62-
63-
<api port="3000" name="Replace with your Backend">
64-
<request>
65-
<!-- Print the request headers.
66-
traceparents will be added to them
67-
showing which spans were involved
68-
in the exchange. -->
69-
<groovy>
70-
println "Request headers:"
71-
header.allHeaderFields.each {
72-
print it
73-
}
74-
CONTINUE
75-
</groovy>
76-
</request>
77-
<response>
78-
<template>Hello from a faked backend!</template>
79-
</response>
80-
<return/>
81-
</api>
82-
</router>
27+
A span created by Membrane should be visible in the [Jaeger UI](http://localhost:16686).
28+
![sample](resources/otel_example.png)
29+
7. Check the headers printed to the console. You will see headers such as `traceparent`, which indicate the trace and span context involved in the request.
30+
31+
**How it is done**
32+
33+
Take a look at the `apis.yml`.
34+
35+
The openTelemetry plugin can be used in several ways:
36+
37+
a.) Globally, in a shared interceptor chain that applies to all APIs (as shown in apis.yaml)
38+
b.) Per API, by defining it directly inside the API flow
39+
c.) With reuseable interceptor chains. See: [Reusable Plugin Chains](../../extending-membrane/reusable-plugin-chains)
40+
41+
Example configuration for a single API:
42+
43+
```yaml
44+
api:
45+
port: 2000
46+
flow:
47+
- openTelemetry:
48+
sampleRate: 1.0
49+
otlpExporter:
50+
host: localhost
51+
port: 4317
52+
transport: grpc
53+
target:
54+
url: http://localhost:2001
8355
```
84-
The `openTelemetry` plugin can be utilized in two ways: either in a global context for all APIs using the `<transport>` tag, as demonstrated above, or it can be specifically defined for individual APIs by placing it within the `<api>` tag.
85-
```xml
86-
<api port="2000">
87-
<openTelemetry sampleRate="1.0">
88-
<otlpExporter host="localhost" port="4317" transport="grpc"/>
89-
</openTelemetry>
90-
<target host="localhost" port="3000"/>
91-
</api>
92-
```
93-
94-
**Note:**
9556
96-
The OTLP Exporter is configured by default to use gRPC. You can omit the `transport` field in the configuration when using gRPC.
57+
**Note on Transport:**
9758
98-
To use HTTP, set the `transport` field to `http` and set the `port` to `4318`.
59+
The OTLP exporter is configured by default to use gRPC. To use HTTP, set the `transport` field to `http` and set the `port` to `4318`.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# yaml-language-server: $schema=https://www.membrane-api.io/v7.0.5.json
2+
3+
global:
4+
- openTelemetry:
5+
sampleRate: 1.0
6+
otlpExporter:
7+
host: localhost
8+
---
9+
10+
api:
11+
name: Entry
12+
port: 2000
13+
target:
14+
url: http://localhost:2001
15+
---
16+
17+
api:
18+
name: Access Control
19+
port: 2001
20+
target:
21+
url: http://localhost:2002
22+
---
23+
24+
api:
25+
name: Validation
26+
port: 2002
27+
target:
28+
url: http://localhost:3000
29+
---
30+
31+
api:
32+
name: Backend
33+
port: 3000
34+
flow:
35+
- request:
36+
- log:
37+
message: "Header: ${header}"
38+
- static:
39+
src: Greetings from the backend!
40+
- return:
41+
status: 200
-45.4 KB
Loading
Binary file not shown.

0 commit comments

Comments
 (0)