diff --git a/dd-trace-core/src/main/java/datadog/trace/common/writer/TraceProcessingWorker.java b/dd-trace-core/src/main/java/datadog/trace/common/writer/TraceProcessingWorker.java index 39430eac71c..9b70b09bc9e 100644 --- a/dd-trace-core/src/main/java/datadog/trace/common/writer/TraceProcessingWorker.java +++ b/dd-trace-core/src/main/java/datadog/trace/common/writer/TraceProcessingWorker.java @@ -9,6 +9,7 @@ import datadog.common.queue.Queues; import datadog.communication.ddagent.DroppingPolicy; import datadog.trace.api.Config; +import datadog.trace.api.internal.VisibleForTesting; import datadog.trace.bootstrap.instrumentation.api.SpanPostProcessor; import datadog.trace.common.sampling.SingleSpanSampler; import datadog.trace.common.writer.ddagent.FlushEvent; @@ -122,6 +123,11 @@ public long getRemainingCapacity() { return primaryQueue.remainingCapacity(); } + @VisibleForTesting + MessagePassingBlockingQueue getPrimaryQueue() { + return primaryQueue; + } + private static MessagePassingBlockingQueue createQueue(int capacity) { return Queues.mpscBlockingConsumerArrayQueue(capacity); } diff --git a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/DDAgentApiTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/common/writer/DDAgentApiTest.groovy deleted file mode 100644 index 0306de5ae39..00000000000 --- a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/DDAgentApiTest.groovy +++ /dev/null @@ -1,538 +0,0 @@ -package datadog.trace.common.writer - -import static datadog.trace.agent.test.server.http.TestHttpServer.httpServer -import static datadog.trace.api.ProtocolVersion.V0_4 -import static datadog.trace.api.ProtocolVersion.V0_5 -import static datadog.trace.api.ProtocolVersion.V1_0 - -import com.fasterxml.jackson.core.type.TypeReference -import com.fasterxml.jackson.databind.ObjectMapper -import datadog.communication.ddagent.DDAgentFeaturesDiscovery -import datadog.communication.http.OkHttpUtils -import datadog.communication.serialization.ByteBufferConsumer -import datadog.communication.serialization.FlushingBuffer -import datadog.communication.serialization.msgpack.MsgPackWriter -import datadog.metrics.api.Monitoring -import datadog.metrics.api.statsd.StatsDClient -import datadog.metrics.impl.MonitoringImpl -import datadog.trace.api.Config -import datadog.trace.api.ProcessTags -import datadog.trace.api.ProtocolVersion -import datadog.trace.api.config.OtlpConfig -import datadog.trace.bootstrap.instrumentation.api.InstrumentationTags -import datadog.trace.common.sampling.RateByServiceTraceSampler -import datadog.trace.common.writer.ddagent.DDAgentApi -import datadog.trace.common.writer.ddagent.TraceMapper -import datadog.trace.common.writer.ddagent.TraceMapperV0_4 -import datadog.trace.common.writer.ddagent.TraceMapperV0_5 -import datadog.trace.common.writer.ddagent.TraceMapperV1 -import datadog.trace.core.DDSpan -import datadog.trace.core.DDSpanContext -import datadog.trace.core.propagation.PropagationTags -import datadog.trace.core.test.DDCoreSpecification -import java.nio.ByteBuffer -import java.util.concurrent.RejectedExecutionException -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicLong -import java.util.concurrent.atomic.AtomicReference -import okhttp3.HttpUrl -import okhttp3.OkHttpClient -import org.msgpack.jackson.dataformat.MessagePackFactory -import spock.lang.Shared -import spock.lang.Timeout - -@Timeout(20) -class DDAgentApiTest extends DDCoreSpecification { - - @Shared - Monitoring monitoring = new MonitoringImpl(StatsDClient.NO_OP, 1, TimeUnit.SECONDS) - static mapper = new ObjectMapper(new MessagePackFactory()) - - static newAgent(String latestVersion) { - httpServer { - handlers { - put(latestVersion) { - if (request.contentType != "application/msgpack") { - response.status(400).send("wrong type: $request.contentType") - } else if (request.contentLength <= 0) { - response.status(400).send("no content") - } else { - response.status(200).send() - } - } - } - } - } - - - def "sending an empty list of traces returns no errors"() { - setup: - def agent = newAgent(agentVersion) - def client = createAgentApi(agent.address.toString(), protocolVersion)[1] - def payload = prepareTraces(agentVersion, []) - - expect: - def response = client.sendSerializedTraces(payload) - response.success() - response.status().present - response.status().asInt == 200 - agent.getLastRequest().path == "/" + agentVersion - - cleanup: - agent.close() - - where: - agentVersion | protocolVersion - "v0.3/traces" | V0_4 - "v0.4/traces" | V0_4 - "v0.5/traces" | V0_5 - "v1.0/traces" | V1_0 - } - - def "response body propagated in case of non-200 response"() { - setup: - def agent = httpServer { - handlers { - put("v0.4/traces") { - response.status(400).send("Test error") - } - } - } - def client = createAgentApi(agent.address.toString())[1] - Payload payload = prepareTraces("v0.4/traces", []) - - expect: - def clientResponse = client.sendSerializedTraces(payload) - !clientResponse.success() - clientResponse.status().present - clientResponse.status().asInt == 400 - clientResponse.response == "Test error" - - cleanup: - agent.close() - } - - def "non-200 response"() { - setup: - def agent = httpServer { - handlers { - put("v0.4/traces") { - response.status(404).send() - } - - put("v0.3/traces") { - response.status(404).send() - } - } - } - def client = createAgentApi(agent.address.toString())[1] - Payload payload = prepareTraces("v0.3/traces", []) - expect: - def clientResponse = client.sendSerializedTraces(payload) - !clientResponse.success() - clientResponse.status().present - clientResponse.status().asInt == 404 - agent.getLastRequest().path == "/v0.3/traces" - - cleanup: - agent.close() - } - - def "content is sent as MSGPACK"() { - setup: - def agent = httpServer { - handlers { - put(agentVersion) { - response.send() - } - } - } - def client = createAgentApi(agent.address.toString())[1] - def payload = prepareTraces(agentVersion, traces) - - expect: - client.sendSerializedTraces(payload).success() - agent.lastRequest.contentType == "application/msgpack" - agent.lastRequest.headers.get("Datadog-Client-Computed-Top-Level") == "true" - agent.lastRequest.headers.get("Datadog-Meta-Lang") == "java" - agent.lastRequest.headers.get("Datadog-Meta-Lang-Version") == System.getProperty("java.version", "unknown") - agent.lastRequest.headers.get("Datadog-Meta-Tracer-Version") == "Stubbed-Test-Version" - agent.lastRequest.headers.get("X-Datadog-Trace-Count") == "${traces.size()}" - agent.lastRequest.headers.get("Datadog-Client-Dropped-P0-Traces") == "${payload.droppedTraces()}" - agent.lastRequest.headers.get("Datadog-Client-Dropped-P0-Spans") == "${payload.droppedSpans()}" - convertList(agentVersion, agent.lastRequest.body) == expectedRequestBody - - cleanup: - agent.close() - - // Populate thread info dynamically as it is different when run via gradle vs idea. - where: - // spotless:off - traces | expectedRequestBody - [] | [] - // service propagation enabled - [[buildSpan(1L, "service.name", "my-service", PropagationTags.factory().fromHeaderValue(PropagationTags.HeaderType.DATADOG, "_dd.p.usr=123"))]] | [[new TreeMap<>([ - "duration" : 10, - "error" : 0, - "meta" : ["thread.name": Thread.currentThread().getName(), "_dd.p.usr": "123", "_dd.p.dm": "-1", "_dd.p.ksr": "1", "_dd.svc_src" : "m"] + - (Config.get().isExperimentalPropagateProcessTagsEnabled() ? ["_dd.tags.process" : ProcessTags.getTagsForSerialization().toString()] : []), - "metrics" : [ - (DDSpanContext.PRIORITY_SAMPLING_KEY) : 1, - (InstrumentationTags.DD_TOP_LEVEL as String) : 1, - (RateByServiceTraceSampler.SAMPLING_AGENT_RATE): 1.0, - "thread.id" : Thread.currentThread().id - ], - "name" : "fakeOperation", - "parent_id": 0, - "resource" : "fakeResource", - "service" : "my-service", - "span_id" : 1, - "start" : 1000, - "trace_id" : 1, - "type" : "fakeType" - ])]] - // service propagation disabled - [[buildSpan(100L, "resource.name", "my-resource", PropagationTags.factory().fromHeaderValue(PropagationTags.HeaderType.DATADOG, "_dd.p.usr=123"))]] | [[new TreeMap<>([ - "duration" : 10, - "error" : 0, - "meta" : ["thread.name": Thread.currentThread().getName(), "_dd.p.usr": "123", "_dd.p.dm": "-1", "_dd.p.ksr": "1"] + - (Config.get().isExperimentalPropagateProcessTagsEnabled() ? ["_dd.tags.process" : ProcessTags.getTagsForSerialization().toString()] : []), - "metrics" : [ - (DDSpanContext.PRIORITY_SAMPLING_KEY) : 1, - (InstrumentationTags.DD_TOP_LEVEL as String) : 1, - (RateByServiceTraceSampler.SAMPLING_AGENT_RATE): 1.0, - "thread.id" : Thread.currentThread().id - ], - "name" : "fakeOperation", - "parent_id": 0, - "resource" : "my-resource", - "service" : "fakeService", - "span_id" : 1, - "start" : 100000, - "trace_id" : 1, - "type" : "fakeType" - ])]] - // spotless:on - - ignore = traces.each { - it.each { - it.finish() - it.@durationNano = 10 - } - } - agentVersion << ["v0.3/traces", "v0.4/traces", "v0.4/traces"] - } - - def "Api ResponseListeners see 200 responses"() { - setup: - def agentResponse = new AtomicReference(null) - RemoteResponseListener responseListener = { String endpoint, Map responseJson -> - agentResponse.set(responseJson) - } - def agent = httpServer { - handlers { - put(agentVersion) { - def status = request.contentLength > 0 ? 200 : 500 - response.status(status).send('{"hello":{}}') - } - } - } - def client = createAgentApi(agent.address.toString())[1] - client.addResponseListener(responseListener) - def payload = prepareTraces(agentVersion, [[], [], []]) - payload.withDroppedTraces(1) - payload.withDroppedTraces(3) - - when: - client.sendSerializedTraces(payload) - then: - agentResponse.get() == ["hello": [:]] - agent.lastRequest.headers.get("Datadog-Meta-Lang") == "java" - agent.lastRequest.headers.get("Datadog-Meta-Lang-Version") == System.getProperty("java.version", "unknown") - agent.lastRequest.headers.get("Datadog-Meta-Tracer-Version") == "Stubbed-Test-Version" - agent.lastRequest.headers.get("X-Datadog-Trace-Count") == "3" // false data shows the value provided via traceCounter. - agent.lastRequest.headers.get("Datadog-Client-Dropped-P0-Traces") == "${payload.droppedTraces()}" - agent.lastRequest.headers.get("Datadog-Client-Dropped-P0-Spans") == "${payload.droppedSpans()}" - - cleanup: - agent.close() - - where: - agentVersion << ["v0.3/traces", "v0.4/traces", "v0.5/traces"] - } - - def "Api Downgrades to v3 if v0.4 not available"() { - setup: - def v3Agent = httpServer { - handlers { - put("v0.3/traces") { - def status = request.contentLength > 0 ? 200 : 500 - response.status(status).send() - } - } - } - def client = createAgentApi(v3Agent.address.toString())[1] - def payload = prepareTraces("v0.4/traces", []) - expect: - client.sendSerializedTraces(payload).success() - v3Agent.getLastRequest().path == "/v0.3/traces" - - cleanup: - v3Agent.close() - } - - def "Api Downgrades to v3 if timeout exceeded (#delayTrace, #badPort)"() { - // This test is unfortunately only exercising the read timeout, not the connect timeout. - setup: - def agent = httpServer { - handlers { - put("v0.3/traces") { - def status = request.contentLength > 0 ? 200 : 500 - response.status(status).send() - } - put("v0.4/traces") { - Thread.sleep(delayTrace) - def status = request.contentLength > 0 ? 200 : 500 - response.status(status).send() - } - } - } - def port = badPort ? 999 : agent.address.port - def client = createAgentApi("http://" + agent.address.host + ":" + port)[1] - def payload = prepareTraces("v0.4/traces", []) - def result = client.sendSerializedTraces(payload) - - expect: - result.success() == !badPort // Expect success of port is ok - if (!badPort) { - assert agent.getLastRequest().path == "/$endpointVersion/traces" - } - - cleanup: - agent.close() - - where: - endpointVersion | delayTrace | badPort - "v0.4" | 0 | false - "v0.3" | 0 | true - "v0.4" | 500 | false - "v0.3" | 30000 | false - } - - def "verify content length"() { - setup: - def receivedContentLength = new AtomicLong() - def agent = httpServer { - handlers { - put(agentVersion) { - receivedContentLength.set(request.contentLength) - response.status(200).send() - } - } - } - def client = createAgentApi(agent.address.toString())[1] - def payload = prepareTraces(agentVersion, traces) - when: - def success = client.sendSerializedTraces(payload).success() - then: - success - receivedContentLength.get() == expectedLength - - cleanup: - agent.close() - - // all the tested traces are empty (why?) and it just so happens that - // arrays and maps take the same amount of space in messagepack, so - // all the sizes match, except in v0.5 where there is 1 byte for a - // 2 element array header and 1 byte for an empty dictionary - where: - // spotless:off - agentVersion | expectedLength | traces - "v0.4/traces" | 1 | [] - "v0.4/traces" | 3 | [[], []] - "v0.4/traces" | 16 | (1..15).collect { [] } - "v0.4/traces" | 19 | (1..16).collect { [] } - "v0.4/traces" | 65538 | (1..((1 << 16) - 1)).collect { [] } - "v0.4/traces" | 65541 | (1..(1 << 16)).collect { [] } - "v0.5/traces" | 1 + 1 + 1 | [] - "v0.5/traces" | 3 + 1 + 1 | [[], []] - "v0.5/traces" | 16 + 1 + 1 | (1..15).collect { [] } - "v0.5/traces" | 19 + 1 + 1 | (1..16).collect { [] } - "v0.5/traces" | 65538 + 1 + 1 | (1..((1 << 16) - 1)).collect { [] } - "v0.5/traces" | 65541 + 1 + 1 | (1..(1 << 16)).collect { [] } - // spotless:on - } - - def "Embedded HTTP client rejects async requests"() { - setup: - def agent = newAgent("v0.5/traces") - def (discovery, client) = createAgentApi(agent.address.toString()) - discovery.discover() - def httpExecutorService = client.httpClient.dispatcher().executorService() - when: - httpExecutorService.execute({}) - then: - thrown RejectedExecutionException - and: - httpExecutorService.isShutdown() - cleanup: - agent.close() - } - - void 'test metaStruct support on the encoded spans'() { - setup: - def agentVersion = 'v0.4/traces' - def meta1 = 'Hello World!' - def meta2 = [Hello: ' World!'] - def agent = httpServer { - handlers { - put(agentVersion) { - response.send() - } - } - } - def client = createAgentApi(agent.address.toString())[1] - def span = buildSpan(1L, "fakeType", [:]) - .setMetaStruct('meta_1', meta1) - .setMetaStruct('meta_2', meta2) - def payload = prepareTraces(agentVersion, [[span]]) - - expect: - client.sendSerializedTraces(payload).success() - def body = convertList(agentVersion, agent.lastRequest.body)[0][0] - def metaStruct = body['meta_struct'] as Map - assert metaStruct.size() == 2 - assert mapper.readValue(metaStruct['meta_1'], String) == meta1 - assert mapper.readValue(metaStruct['meta_2'], Map) == meta2 - - cleanup: - agent.close() - } - - static List>> convertList(String agentVersion, byte[] bytes) { - if (agentVersion.equals("v0.5/traces")) { - return convertListV5(bytes) - } - def returnVal = mapper.readValue(bytes, new TypeReference>>>() {}) - returnVal.each { - it.each { - it["meta"].remove("runtime-id") - it["meta"].remove("language") - } - } - - return returnVal - } - - static List>> convertListV5(byte[] bytes) { - List>> traces = mapper.readValue(bytes, new TypeReference>>>() {}) - List>> maps = new ArrayList<>(traces.size()) - for (List> trace : traces) { - List> mapTrace = new ArrayList<>() - for (List span : trace) { - TreeMap map = new TreeMap<>() - if (!span.isEmpty()) { - map.put("service", span.get(0)) - map.put("name", span.get(1)) - map.put("resource", span.get(2)) - map.put("trace_id", span.get(3)) - map.put("span_id", span.get(4)) - map.put("parent_id", span.get(5)) - map.put("start", span.get(6)) - map.put("duration", span.get(7)) - map.put("error", span.get(8)) - map.put("meta", span.get(9)) - map.put("metrics", span.get(10)) - map.put("type", span.get(11)) - - map.get("meta").remove("runtime-id") - map.get("meta").remove("language") - } - mapTrace.add(map) - } - maps.add(mapTrace) - } - return maps - } - - def "Datadog-Client-Computed-Stats header set when either stats pipeline is enabled (otlpSpanMetrics=#otlpSpanMetrics, nativeMetrics=#nativeMetrics)"() { - setup: - injectSysConfig(OtlpConfig.OTEL_TRACES_SPAN_METRICS_ENABLED, "$otlpSpanMetrics") - - def agent = httpServer { - handlers { - put("v0.4/traces") { - response.status(200).send() - } - } - } - def agentUrl = HttpUrl.get(agent.address.toString()) - - // Mock feature discovery so the native-stats pipeline signal can be controlled independently of - // what the (embedded) agent advertises. supportsMetrics() reflects agent-side client stats support. - def discovery = Mock(DDAgentFeaturesDiscovery) - discovery.getTraceEndpoint() >> "v0.4/traces" - discovery.supportsMetrics() >> nativeMetrics - discovery.state() >> null - - def client = OkHttpUtils.buildHttpClient(agentUrl, 1000) - // nativeMetricsEnabled is the constructor flag WriterFactory sets from Config.isTracerMetricsEnabled(). - def api = new DDAgentApi(client, agentUrl, discovery, monitoring, nativeMetrics) - def payload = prepareTraces("v0.4/traces", []) - - when: - // Named clientResponse (not response) to avoid shadowing the httpServer handler closure's `response`. - def clientResponse = api.sendSerializedTraces(payload) - - then: - clientResponse.success() - (agent.lastRequest.headers.get("Datadog-Client-Computed-Stats") == "true") == expectedComputesStats - - cleanup: - agent.close() - - where: - otlpSpanMetrics | nativeMetrics | expectedComputesStats - true | false | true // gap case: OTLP span metrics on, native stats off - false | false | false // neither pipeline computes stats - false | true | true // native stats on (regression guard) - true | true | true // both on - } - - Payload prepareTraces(String agentVersion, List> traces) { - Traces traceCapture = new Traces() - def packer = new MsgPackWriter(new FlushingBuffer(1 << 20, traceCapture)) - - TraceMapper traceMapper = [ - "v1.0/traces": new TraceMapperV1(), - "v0.5/traces": new TraceMapperV0_5(), - ].get(agentVersion, new TraceMapperV0_4()) - - for (trace in traces) { - packer.format(trace, traceMapper) - } - packer.flush() - - return traceMapper.newPayload() - .withBody(traceCapture.traceCount, - traces.isEmpty() ? ByteBuffer.allocate(0) : traceCapture.buffer) - } - - static class Traces implements ByteBufferConsumer { - int traceCount - ByteBuffer buffer - - @Override - void accept(int messageCount, ByteBuffer buffer) { - this.buffer = buffer - this.traceCount = messageCount - } - } - - def createAgentApi(String url, ProtocolVersion protocolVersion = V0_5) { - HttpUrl agentUrl = HttpUrl.get(url) - OkHttpClient client = OkHttpUtils.buildHttpClient(agentUrl, 1000) - DDAgentFeaturesDiscovery discovery = new DDAgentFeaturesDiscovery(client, monitoring, agentUrl, protocolVersion, true, false) - return [discovery, new DDAgentApi(client, agentUrl, discovery, monitoring, false)] - } -} diff --git a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/DDAgentWriterCombinedTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/common/writer/DDAgentWriterCombinedTest.groovy deleted file mode 100644 index 1659ab44da3..00000000000 --- a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/DDAgentWriterCombinedTest.groovy +++ /dev/null @@ -1,753 +0,0 @@ -package datadog.trace.common.writer - -import static datadog.trace.agent.test.server.http.TestHttpServer.httpServer -import static datadog.trace.api.ProtocolVersion.V0_5 -import static datadog.trace.api.config.GeneralConfig.EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED -import static datadog.trace.common.writer.DDAgentWriter.BUFFER_SIZE -import static datadog.trace.common.writer.ddagent.Prioritization.ENSURE_TRACE - -import datadog.communication.ddagent.DDAgentFeaturesDiscovery -import datadog.communication.http.OkHttpUtils -import datadog.communication.serialization.ByteBufferConsumer -import datadog.communication.serialization.FlushingBuffer -import datadog.communication.serialization.Mapper -import datadog.communication.serialization.msgpack.MsgPackWriter -import datadog.metrics.api.statsd.StatsDClient -import datadog.metrics.impl.MonitoringImpl -import datadog.trace.api.DDSpanId -import datadog.trace.api.DDTraceId -import datadog.trace.api.ProcessTags -import datadog.trace.api.datastreams.NoopPathwayContext -import datadog.trace.api.sampling.PrioritySampling -import datadog.trace.common.writer.ddagent.DDAgentApi -import datadog.trace.common.writer.ddagent.TraceMapperV0_4 -import datadog.trace.common.writer.ddagent.TraceMapperV0_5 -import datadog.trace.core.CoreTracer -import datadog.trace.core.DDSpan -import datadog.trace.core.DDSpanContext -import datadog.trace.core.PendingTrace -import datadog.trace.core.monitor.HealthMetrics -import datadog.trace.core.monitor.TracerHealthMetrics -import datadog.trace.core.propagation.PropagationTags -import datadog.trace.core.test.DDCoreSpecification -import datadog.trace.test.util.Flaky -import java.nio.ByteBuffer -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Phaser -import java.util.concurrent.Semaphore -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicInteger -import okhttp3.HttpUrl -import spock.lang.Timeout -import spock.util.concurrent.PollingConditions - -@Timeout(10) -class DDAgentWriterCombinedTest extends DDCoreSpecification { - - def conditions = new PollingConditions(timeout: 5, initialDelay: 0, factor: 1.25) - def monitoring = new MonitoringImpl(StatsDClient.NO_OP, 1, TimeUnit.SECONDS) - def phaser = new Phaser() - - // Only used to create spans - def dummyTracer = tracerBuilder().writer(new ListWriter()).build() - - def apiWithVersion(String version) { - return Mock(DDAgentApi) - } - - def setup() { - // Register for two threads. - phaser.register() - phaser.register() - } - - def cleanup() { - dummyTracer?.close() - } - - def "no interactions because of initial flush"() { - setup: - def api = apiWithVersion(agentVersion) - def writer = DDAgentWriter.builder() - .agentApi(api) - .traceBufferSize(8) - .monitoring(monitoring) - .flushIntervalMilliseconds(-1) - .build() - writer.start() - - when: - writer.flush() - - then: - 0 * _ - - cleanup: - writer.close() - - where: - agentVersion << ["v0.3/traces", "v0.4/traces", "v0.5/traces"] - } - - def "test happy path"() { - setup: - def api = Mock(DDAgentApi) - def discovery = Mock(DDAgentFeaturesDiscovery) - def writer = DDAgentWriter.builder() - .featureDiscovery(discovery) - .agentApi(api) - .traceBufferSize(1024) - .monitoring(monitoring) - .flushIntervalMilliseconds(-1) - .build() - writer.start() - def trace = [dummyTracer.buildSpan("datadog", "fakeOperation").start()] - - when: - writer.write(trace) - writer.write(trace) - writer.flush() - - then: - 2 * discovery.getTraceEndpoint() >> agentVersion - 1 * api.sendSerializedTraces({ it.traceCount() == 2 }) >> RemoteApi.Response.success(200) - 0 * _ - - cleanup: - writer.close() - - where: - agentVersion << ["v0.3/traces", "v0.4/traces", "v0.5/traces"] - } - - def "test flood of traces"() { - setup: - def api = Mock(DDAgentApi) - def discovery = Mock(DDAgentFeaturesDiscovery) - def writer = DDAgentWriter.builder() - .featureDiscovery(discovery) - .agentApi(api) - .traceBufferSize(bufferSize) - .monitoring(monitoring) - .flushIntervalMilliseconds(-1) - .build() - writer.start() - def trace = [dummyTracer.buildSpan("datadog", "fakeOperation").start()] - - when: - (1..traceCount).each { - writer.write(trace) - } - writer.flush() - - then: - 2 * discovery.getTraceEndpoint() >> agentVersion - 1 * api.sendSerializedTraces({ it.traceCount() <= traceCount }) >> RemoteApi.Response.success(200) - 0 * _ - - cleanup: - writer.close() - - where: - bufferSize = 1024 - traceCount = 100 // Shouldn't trigger payload, but bigger than the disruptor size. - agentVersion << ["v0.3/traces", "v0.4/traces", "v0.5/traces"] - } - - def "test flush by time"() { - setup: - def healthMetrics = Mock(HealthMetrics) - def api = Mock(DDAgentApi) - def discovery = Mock(DDAgentFeaturesDiscovery) - def writer = DDAgentWriter.builder() - .featureDiscovery(discovery) - .agentApi(api) - .healthMetrics(healthMetrics) - .monitoring(monitoring) - .flushIntervalMilliseconds(1000) - .build() - writer.start() - def span = dummyTracer.buildSpan("datadog", "fakeOperation").start() - def trace = (1..10).collect { span } - - when: - (1..5).each { - writer.write(trace) - } - phaser.awaitAdvanceInterruptibly(phaser.arriveAndDeregister()) - - then: - 2 * discovery.getTraceEndpoint() >> agentVersion - 1 * healthMetrics.onSerialize(_) - 1 * api.sendSerializedTraces({ it.traceCount() == 5 }) >> RemoteApi.Response.success(200) - _ * healthMetrics.onPublish(_, _) - 1 * healthMetrics.onSend(_, _, _) >> { - phaser.arrive() - } - 0 * _ - - cleanup: - writer.close() - - where: - agentVersion << ["v0.3/traces", "v0.4/traces", "v0.5/traces"] - } - - @Timeout(30) - def "test default buffer size for #agentVersion"() { - setup: - // disable process tags since they are only written on the first span and it will break the trace size estimation - injectSysConfig(EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED, "false") - ProcessTags.reset() - def api = Mock(DDAgentApi) - def discovery = Mock(DDAgentFeaturesDiscovery) - def writer = DDAgentWriter.builder() - .featureDiscovery(discovery) - .agentApi(api) - .traceBufferSize(BUFFER_SIZE) - .prioritization(ENSURE_TRACE) - .monitoring(monitoring) - .flushIntervalMilliseconds(-1) - .build() - writer.start() - - when: - def mapper = agentVersion.equals("v0.5/traces") ? new TraceMapperV0_5() : new TraceMapperV0_4() - int traceSize = calculateSize(minimalTrace, mapper) - int maxedPayloadTraceCount = ((int) ((mapper.messageBufferSize()) / traceSize)) - (0..maxedPayloadTraceCount).each { - writer.write(minimalTrace) - } - writer.flush() - - then: - 2 * discovery.getTraceEndpoint() >> agentVersion - 1 * api.sendSerializedTraces({ it.traceCount() == maxedPayloadTraceCount }) >> RemoteApi.Response.success(200) - 1 * api.sendSerializedTraces({ it.traceCount() == 1 }) >> RemoteApi.Response.success(200) - 0 * _ - - cleanup: - injectSysConfig(EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED, "true") - ProcessTags.reset() - writer.close() - - where: - minimalTrace = createMinimalTrace() - agentVersion << ["v0.3/traces", "v0.4/traces", "v0.5/traces"] - } - - def "check that there are no interactions after close"() { - setup: - def healthMetrics = Mock(HealthMetrics) - def api = Mock(DDAgentApi) - def discovery = Mock(DDAgentFeaturesDiscovery) - def writer = DDAgentWriter.builder() - .featureDiscovery(discovery) - .agentApi(api) - .healthMetrics(healthMetrics) - .monitoring(monitoring) - .build() - writer.start() - - when: - writer.close() - writer.write([]) - writer.flush() - - then: - // this will be checked during flushing - 1 * healthMetrics.onFailedPublish(_,_) - 1 * healthMetrics.onFlush(_) - 1 * healthMetrics.onShutdown(_) - 1 * healthMetrics.close() - 0 * _ - - cleanup: - writer.close() - - where: - agentVersion << ["v0.3/traces", "v0.4/traces", "v0.5/traces"] - } - - def createMinimalContext() { - def tracer = Stub(CoreTracer) - def trace = Stub(PendingTrace) - trace.mapServiceName(_) >> { String serviceName -> serviceName } - trace.getTracer() >> tracer - return new DDSpanContext( - DDTraceId.ONE, - 1, - DDSpanId.ZERO, - "", - "", - "", - "", - PrioritySampling.UNSET, - "", - [:], - false, - "", - 0, - trace, - null, - null, - NoopPathwayContext.INSTANCE, - false, - PropagationTags.factory().empty()) - } - - def createMinimalTrace() { - def context = createMinimalContext() - def minimalSpan = new DDSpan("test", 0, context, null) - context.getTraceCollector().getRootSpan() >> minimalSpan - def minimalTrace = [minimalSpan] - - return minimalTrace - } - - def "monitor happy path"() { - setup: - def healthMetrics = Mock(HealthMetrics) - def minimalTrace = createMinimalTrace() - - // DQH -- need to set-up a dummy agent for the final send callback to work - def agent = httpServer { - handlers { - put(agentVersion) { - response.status(200).send() - } - } - } - def agentUrl = HttpUrl.get(agent.address) - def client = OkHttpUtils.buildHttpClient(agentUrl, 1000) - def discovery = new DDAgentFeaturesDiscovery(client, monitoring, agentUrl, V0_5, true, false) - def api = new DDAgentApi(client, agentUrl, discovery, monitoring, true) - def writer = DDAgentWriter.builder() - .featureDiscovery(discovery) - .agentApi(api) - .monitoring(monitoring) - .healthMetrics(healthMetrics).build() - - when: - writer.start() - - then: - 1 * healthMetrics.onStart(writer.getCapacity()) - - when: - writer.write(minimalTrace) - writer.flush() - - then: - 1 * healthMetrics.onPublish(minimalTrace, _) - 1 * healthMetrics.onSerialize(_) - 1 * healthMetrics.onFlush(false) - 1 * healthMetrics.onSend(1, _, { response -> response.success() && response.status().present && response.status().asInt == 200 }) - - when: - writer.close() - - then: - 1 * healthMetrics.onShutdown(true) - - cleanup: - agent.close() - - where: - agentVersion << ["v0.3/traces", "v0.4/traces", "v0.5/traces"] - } - - def "monitor agent returns error"() { - setup: - def healthMetrics = Mock(HealthMetrics) - def minimalTrace = createMinimalTrace() - - // DQH -- need to set-up a dummy agent for the final send callback to work - def first = true - def agent = httpServer { - handlers { - put(agentVersion) { - // DQH - DDApi sniffs for end point existence, so respond with 200 the first time - if (first) { - response.status(200).send() - first = false - } else { - response.status(500).send() - } - } - } - } - def agentUrl = HttpUrl.get(agent.address) - def client = OkHttpUtils.buildHttpClient(agentUrl, 1000) - def discovery = new DDAgentFeaturesDiscovery(client, monitoring, agentUrl, V0_5, true, false) - def api = new DDAgentApi(client, agentUrl, discovery, monitoring, true) - def writer = DDAgentWriter.builder() - .featureDiscovery(discovery) - .agentApi(api) - .monitoring(monitoring) - .healthMetrics(healthMetrics).build() - - when: - writer.start() - - then: - 1 * healthMetrics.onStart(writer.getCapacity()) - - when: - writer.write(minimalTrace) - writer.flush() - - then: - 1 * healthMetrics.onPublish(minimalTrace, _) - 1 * healthMetrics.onSerialize(_) - 1 * healthMetrics.onFlush(false) - 1 * healthMetrics.onFailedSend(1, _, { response -> !response.success() && response.status().present && response.status().asInt == 500 }) - - when: - writer.close() - - then: - 1 * healthMetrics.onShutdown(true) - - cleanup: - agent.close() - - where: - agentVersion << ["v0.3/traces", "v0.4/traces", "v0.5/traces"] - } - - def "unreachable agent test"() { - setup: - def healthMetrics = Mock(HealthMetrics) - def minimalTrace = createMinimalTrace() - def version = agentVersion - def discovery = Stub(DDAgentFeaturesDiscovery) { - it.getTraceEndpoint() >> version - } - def api = Mock(DDAgentApi) { - it.sendSerializedTraces(_) >> { - // simulating a communication failure to a server - return RemoteApi.Response.failed(new IOException("comm error")) - } - } - - def writer = DDAgentWriter.builder() - .featureDiscovery(discovery) - .agentApi(api) - .monitoring(monitoring) - .healthMetrics(healthMetrics).build() - - when: - writer.start() - - then: - 1 * healthMetrics.onStart(writer.getCapacity()) - - when: - writer.write(minimalTrace) - writer.flush() - - then: - // if we know there's no agent, we'll drop the traces before serialising them - // but we also know that there's nowhere to send health metrics to - 1 * healthMetrics.onPublish(_, _) - 1 * healthMetrics.onFlush(false) - - when: - writer.close() - - then: - 1 * healthMetrics.onShutdown(true) - - where: - agentVersion << ["v0.3/traces", "v0.4/traces", "v0.5/traces"] - } - - @Flaky("If execution is too slow, the http client timeout may trigger") - def "slow response test"() { - def numWritten = 0 - def numFlushes = new AtomicInteger(0) - def numPublished = new AtomicInteger(0) - def numFailedPublish = new AtomicInteger(0) - def numRequests = new AtomicInteger(0) - def numFailedRequests = new AtomicInteger(0) - - def responseSemaphore = new Semaphore(1) - - setup: - def version = agentVersion - - // Need to set-up a dummy agent for the final send callback to work - def agent = httpServer { - handlers { - put(version) { - responseSemaphore.acquire() - try { - response.status(200).send() - } finally { - responseSemaphore.release() - } - } - } - } - - // This test focuses just on failed publish, so not verifying every callback - def healthMetrics = Stub(HealthMetrics) { - onPublish(_, _) >> { - numPublished.incrementAndGet() - } - onFailedPublish(_,_) >> { - numFailedPublish.incrementAndGet() - } - onFlush(_) >> { - numFlushes.incrementAndGet() - } - onSend(_, _, _) >> { - numRequests.incrementAndGet() - } - onFailedSend(_, _, _) >> { - numFailedRequests.incrementAndGet() - } - } - - def writer = DDAgentWriter.builder() - .traceAgentProtocolVersion(V0_5) - .traceAgentPort(agent.address.port) - .monitoring(monitoring) - .healthMetrics(healthMetrics) - .traceBufferSize(bufferSize).build() - writer.start() - - // gate responses - responseSemaphore.acquire() - - when: - // write a single trace and flush - // with responseSemaphore held, the response is blocked but may still time out - writer.write(minimalTrace) - numWritten += 1 - - // sanity check coordination mechanism of test - // release to allow response to be generated - responseSemaphore.release() - writer.flush() - - // reacquire semaphore to stall further responses - responseSemaphore.acquire() - - then: - numFailedPublish.get() == 0 - numPublished.get() == numWritten - numPublished.get() + numFailedPublish.get() == numWritten - numFlushes.get() == 1 - - when: - // send many traces to fill the sender queue... - // loop until outstanding requests > finished requests - while (writer.traceProcessingWorker.getRemainingCapacity() > 0 || numFailedPublish.get() == 0) { - writer.write(minimalTrace) - numWritten += 1 - } - - then: - numFailedPublish.get() > 0 - numPublished.get() + numFailedPublish.get() == numWritten - - when: - - // with both disruptor & queue full, should reject everything - def expectedRejects = 100 - (1..expectedRejects).each { - writer.write(minimalTrace) - numWritten += 1 - } - - then: - numPublished.get() + numFailedPublish.get() == numWritten - - cleanup: - responseSemaphore.release() - - writer.close() - agent.close() - - where: - bufferSize = 16 - minimalTrace = createMinimalTrace() - agentVersion << ["v0.3/traces", "v0.4/traces", "v0.5/traces"] - } - - def "multi threaded"() { - def numPublished = new AtomicInteger(0) - def numFailedPublish = new AtomicInteger(0) - def numRepSent = new AtomicInteger(0) - - setup: - def minimalTrace = createMinimalTrace() - def version = agentVersion - - // Need to set-up a dummy agent for the final send callback to work - def agent = httpServer { - handlers { - put(version) { - response.status(200).send() - } - } - } - - // This test focuses just on failed publish, so not verifying every callback - def healthMetrics = Stub(HealthMetrics) { - onPublish(_, _) >> { - numPublished.incrementAndGet() - } - onFailedPublish(_,_) >> { - numFailedPublish.incrementAndGet() - } - onSend(_, _, _) >> { repCount, sizeInBytes, response -> - numRepSent.addAndGet(repCount) - } - } - - def writer = DDAgentWriter.builder() - .traceAgentProtocolVersion(V0_5) - .traceAgentPort(agent.address.port) - .monitoring(monitoring) - .healthMetrics(healthMetrics).build() - writer.start() - - when: - def producer = { - (1..100).each { - writer.write(minimalTrace) - } - } as Runnable - - def t1 = new Thread(producer) - t1.start() - - def t2 = new Thread(producer) - t2.start() - - t1.join() - t2.join() - - writer.flush() - - then: - conditions.eventually { - def totalTraces = 100 + 100 - assert numPublished.get() == totalTraces - assert numRepSent.get() == totalTraces - } - - cleanup: - writer.close() - agent.close() - - where: - agentVersion << ["v0.3/traces", "v0.4/traces", "v0.5/traces"] - } - - def "statsd success"() { - def numTracesAccepted = new AtomicInteger(0) - def numRequests = new AtomicInteger(0) - def numResponses = new AtomicInteger(0) - - setup: - def minimalTrace = createMinimalTrace() - def version = agentVersion - - // Need to set-up a dummy agent for the final send callback to work - def agent = httpServer { - handlers { - put(version) { - response.status(200).send() - } - } - } - - def healthMetrics = Stub(HealthMetrics) - healthMetrics.onPublish(_, _) >> { - numTracesAccepted.incrementAndGet() - } - healthMetrics.onSend(_, _, _) >> { - numRequests.incrementAndGet() - numResponses.incrementAndGet() - } - def writer = DDAgentWriter.builder() - .agentHost(agent.address.host) - .traceAgentProtocolVersion(V0_5) - .traceAgentPort(agent.address.port) - .monitoring(monitoring) - .healthMetrics(healthMetrics).build() - writer.start() - - when: - writer.write(minimalTrace) - writer.flush() - - then: - numTracesAccepted.get() == 1 - numRequests.get() == 1 - numResponses.get() == 1 - - cleanup: - agent.close() - writer.close() - - where: - agentVersion << ["v0.3/traces", "v0.4/traces", "v0.5/traces"] - } - - def "statsd comm failure"() { - setup: - def minimalTrace = createMinimalTrace() - - def api = apiWithVersion(agentVersion) - api.sendSerializedTraces(_) >> RemoteApi.Response.failed(new IOException("comm error")) - - def latch = new CountDownLatch(2) - def statsd = Mock(StatsDClient) - def healthMetrics = new TracerHealthMetrics(statsd, 100, TimeUnit.MILLISECONDS) - def writer = DDAgentWriter.builder() - .traceAgentProtocolVersion(V0_5) - .agentApi(api).monitoring(monitoring) - .healthMetrics(healthMetrics).build() - healthMetrics.start() - writer.start() - - when: - writer.write(minimalTrace) - writer.flush() - latch.await(10, TimeUnit.SECONDS) - - then: - 1 * statsd.count("api.requests.total", 1, _) >> { - latch.countDown() - } - 0 * statsd.incrementCounter("api.responses.total", _) - 1 * statsd.count("api.errors.total", 1, _) >> { - latch.countDown() - } - - cleanup: - writer.close() - healthMetrics.close() - - where: - agentVersion << ["v0.3/traces", "v0.4/traces", "v0.5/traces"] - } - - static int calculateSize(List trace, Mapper> mapper) { - AtomicInteger size = new AtomicInteger() - def packer = new MsgPackWriter(new FlushingBuffer(1024, new ByteBufferConsumer() { - @Override - void accept(int messageCount, ByteBuffer buffer) { - size.set(buffer.limit() - buffer.position()) - } - })) - packer.format(trace, mapper) - packer.flush() - return size.get() - } -} diff --git a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/DDAgentWriterTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/common/writer/DDAgentWriterTest.groovy deleted file mode 100644 index 965f3a88d5e..00000000000 --- a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/DDAgentWriterTest.groovy +++ /dev/null @@ -1,222 +0,0 @@ -package datadog.trace.common.writer - -import datadog.communication.ddagent.DDAgentFeaturesDiscovery -import datadog.trace.api.DDSpanId -import datadog.trace.api.DDTraceId -import datadog.trace.api.sampling.PrioritySampling -import datadog.trace.api.datastreams.NoopPathwayContext -import datadog.trace.common.writer.ddagent.DDAgentApi -import datadog.trace.common.writer.ddagent.DDAgentMapperDiscovery -import datadog.metrics.api.statsd.StatsDClient -import datadog.metrics.impl.MonitoringImpl -import datadog.trace.core.CoreTracer -import datadog.trace.core.DDSpan -import datadog.trace.core.DDSpanContext -import datadog.trace.core.PendingTrace -import datadog.trace.core.monitor.HealthMetrics -import datadog.trace.core.propagation.PropagationTags -import datadog.trace.core.test.DDCoreSpecification -import spock.lang.Subject - -import java.util.concurrent.TimeUnit - -import static datadog.trace.common.writer.ddagent.PrioritizationStrategy.PublishResult.DROPPED_BUFFER_OVERFLOW -import static datadog.trace.common.writer.ddagent.PrioritizationStrategy.PublishResult.DROPPED_BY_POLICY -import static datadog.trace.common.writer.ddagent.PrioritizationStrategy.PublishResult.ENQUEUED_FOR_SERIALIZATION -import static datadog.trace.common.writer.ddagent.PrioritizationStrategy.PublishResult.ENQUEUED_FOR_SINGLE_SPAN_SAMPLING - -class DDAgentWriterTest extends DDCoreSpecification { - - def monitor = Mock(HealthMetrics) - def worker = Mock(TraceProcessingWorker) - def discovery = Mock(DDAgentFeaturesDiscovery) - def api = Mock(DDAgentApi) - def monitoring = new MonitoringImpl(StatsDClient.NO_OP, 1, TimeUnit.SECONDS) - def dispatcher = new PayloadDispatcherImpl(new DDAgentMapperDiscovery(discovery), api, monitor, monitoring) - - @Subject - def writer = new DDAgentWriter(worker, dispatcher, monitor, 1, TimeUnit.SECONDS, false) - - // Only used to create spans - def dummyTracer = tracerBuilder().writer(new ListWriter()).build() - - def cleanup() { - writer.close() - dummyTracer.close() - } - - def "test writer builder"() { - when: - def writer = DDAgentWriter.builder().build() - - then: - writer != null - } - - def "test writer.start"() { - when: - writer.start() - - then: - 1 * monitor.start() - 1 * worker.start() - 1 * worker.getCapacity() >> capacity - 1 * monitor.onStart(capacity) - 0 * _ - - where: - capacity = 5 - } - - def "test writer.start closed"() { - setup: - writer.close() - - when: - writer.start() - - then: - 0 * _ - } - - def "test writer.flush"() { - when: - writer.flush() - - then: - 1 * worker.flush(1, TimeUnit.SECONDS) >> true - 1 * monitor.onFlush(false) - 0 * _ - - when: - writer.flush() - - then: - 1 * worker.flush(1, TimeUnit.SECONDS) >> false - 0 * _ - } - - def "test writer.flush closed"() { - setup: - writer.close() - - when: - writer.flush() - - then: - 0 * _ - } - - def "test writer.write publish succeeds"() { - setup: - def trace = [dummyTracer.buildSpan("datadog", "fakeOperation").start()] - - when: "publish succeeds" - writer.write(trace) - - then: "monitor is notified of successful publication" - 1 * worker.publish(_, _, trace) >> ENQUEUED_FOR_SERIALIZATION - 1 * monitor.onPublish(trace, _) - 0 * _ - } - - def "test writer.write publish for single span sampling"() { - setup: - def trace = [dummyTracer.buildSpan("datadog", "fakeOperation").start()] - - when: "publish succeeds" - writer.write(trace) - - then: "monitor is notified of successful publication" - 1 * worker.publish(_, _, trace) >> ENQUEUED_FOR_SINGLE_SPAN_SAMPLING - // shouldn't call monitor.onPublish - 0 * _ - } - - def "test writer.write publish fails"() { - setup: - def trace = [dummyTracer.buildSpan("datadog", "fakeOperation").start()] - - when: "publish fails" - writer.write(trace) - - then: "monitor is notified of unsuccessful publication" - 1 * worker.publish(_, _, trace) >> publishResult - 1 * monitor.onFailedPublish(_,_) - 0 * _ - - where: - publishResult << [DROPPED_BUFFER_OVERFLOW, DROPPED_BY_POLICY] - } - - def "empty traces should be reported as failures"() { - when: "trace is empty" - writer.write([]) - - then: "monitor is notified of unsuccessful publication" - 1 * monitor.onFailedPublish(_,_) - 0 * _ - } - - def "test writer.write closed"() { - setup: - writer.close() - def trace = [dummyTracer.buildSpan("datadog", "fakeOperation").start()] - - when: - writer.write(trace) - - then: - 1 * monitor.onFailedPublish(_,_) - 0 * _ - } - - def "dropped trace is counted"() { - setup: - def worker = Mock(TraceProcessingWorker) - def monitor = Stub(HealthMetrics) - def dispatcher = Mock(PayloadDispatcherImpl) - def writer = new DDAgentWriter(worker, dispatcher, monitor, 1, TimeUnit.SECONDS, false) - def p0 = newSpan() - p0.setSamplingPriority(PrioritySampling.SAMPLER_DROP) - def trace = [p0, newSpan()] - - when: - writer.write(trace) - - then: - 1 * worker.publish(trace[0], PrioritySampling.SAMPLER_DROP, trace) >> publishResult - 1 * dispatcher.onDroppedTrace(trace.size()) - - where: - publishResult << [DROPPED_BY_POLICY, DROPPED_BUFFER_OVERFLOW] - } - - def newSpan() { - CoreTracer tracer = Stub(CoreTracer) - PendingTrace trace = Stub(PendingTrace) - trace.mapServiceName(_) >> { String serviceName -> serviceName } - trace.getTracer() >> tracer - def context = new DDSpanContext( - DDTraceId.ONE, - 1, - DDSpanId.ZERO, - null, - "", - "", - "", - PrioritySampling.UNSET, - "", - [:], - false, - "", - 0, - trace, - null, - null, - NoopPathwayContext.INSTANCE, - false, - PropagationTags.factory().empty()) - return new DDSpan("test", 0, context, null) - } -} diff --git a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/DDIntakeWriterCombinedTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/common/writer/DDIntakeWriterCombinedTest.groovy deleted file mode 100644 index 966d4e91ecb..00000000000 --- a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/DDIntakeWriterCombinedTest.groovy +++ /dev/null @@ -1,769 +0,0 @@ -package datadog.trace.common.writer - -import datadog.communication.http.OkHttpUtils -import datadog.communication.serialization.ByteBufferConsumer -import datadog.communication.serialization.FlushingBuffer -import datadog.communication.serialization.msgpack.MsgPackWriter -import datadog.trace.api.DDSpanId -import datadog.trace.api.DDTraceId -import datadog.trace.api.civisibility.CiVisibilityWellKnownTags -import datadog.trace.api.intake.TrackType -import datadog.trace.api.sampling.PrioritySampling -import datadog.trace.api.datastreams.NoopPathwayContext -import datadog.metrics.impl.MonitoringImpl -import datadog.metrics.api.statsd.StatsDClient -import datadog.trace.common.writer.ddintake.DDIntakeApi -import datadog.trace.common.writer.ddintake.DDIntakeMapperDiscovery -import datadog.trace.core.CoreTracer -import datadog.trace.core.DDSpan -import datadog.trace.core.DDSpanContext -import datadog.trace.core.PendingTrace -import datadog.trace.core.monitor.HealthMetrics -import datadog.trace.core.monitor.TracerHealthMetrics -import datadog.trace.core.propagation.PropagationTags -import datadog.trace.core.test.DDCoreSpecification -import datadog.trace.test.util.Flaky -import okhttp3.HttpUrl -import spock.lang.Shared -import spock.lang.Timeout -import spock.util.concurrent.PollingConditions - -import java.nio.ByteBuffer -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Phaser -import java.util.concurrent.Semaphore -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicInteger - -import static datadog.trace.agent.test.server.http.TestHttpServer.httpServer -import static datadog.trace.common.writer.DDIntakeWriter.BUFFER_SIZE -import static datadog.trace.common.writer.ddagent.Prioritization.ENSURE_TRACE - -@Timeout(10) -class DDIntakeWriterCombinedTest extends DDCoreSpecification { - - @Shared - def wellKnownTags = new CiVisibilityWellKnownTags( - "my-runtime-id", "my-env", "my-language", - "my-runtime-name", "my-runtime-version", "my-runtime-vendor", - "my-os-arch", "my-os-platform", "my-os-version", "false") - - def conditions = new PollingConditions(timeout: 5, initialDelay: 0, factor: 1.25) - def monitoring = new MonitoringImpl(StatsDClient.NO_OP, 1, TimeUnit.SECONDS) - def phaser = new Phaser() - - // Only used to create spans - def dummyTracer = tracerBuilder().writer(new ListWriter()).build() - - def setup() { - // Register for two threads. - phaser.register() - phaser.register() - } - - def cleanup() { - dummyTracer?.close() - } - - def "no interactions because of initial flush"() { - setup: - def api = Mock(DDIntakeApi) - def writer = DDIntakeWriter.builder() - .addTrack(TrackType.NOOP, api) - .traceBufferSize(8) - .monitoring(monitoring) - .flushIntervalMilliseconds(-1) - .alwaysFlush(false) - .build() - writer.start() - - when: - writer.flush() - - then: - 0 * _ - - cleanup: - writer.close() - } - - def "test happy path"() { - setup: - def api = Mock(DDIntakeApi) - def writer = DDIntakeWriter.builder() - .addTrack(trackType, api) - .traceBufferSize(1024) - .monitoring(monitoring) - .flushIntervalMilliseconds(-1) - .alwaysFlush(false) - .build() - writer.start() - def trace = [dummyTracer.buildSpan("datadog", "fakeOperation").start()] - - when: - writer.write(trace) - writer.write(trace) - writer.flush() - - then: - 1 * api.sendSerializedTraces({ it.traceCount() == 2 }) >> RemoteApi.Response.success(200) - 0 * _ - - cleanup: - writer.close() - - where: - trackType << [TrackType.CITESTCYCLE] - } - - def "test flood of traces"() { - setup: - def api = Mock(DDIntakeApi) - def writer = DDIntakeWriter.builder() - .addTrack(trackType, api) - .traceBufferSize(1024) - .monitoring(monitoring) - .flushIntervalMilliseconds(-1) - .alwaysFlush(false) - .build() - writer.start() - def trace = [dummyTracer.buildSpan("datadog", "fakeOperation").start()] - - when: - (1..traceCount).each { - writer.write(trace) - } - writer.flush() - - then: - 1 * api.sendSerializedTraces({ it.traceCount() <= traceCount }) >> RemoteApi.Response.success(200) - 0 * _ - - cleanup: - writer.close() - - where: - bufferSize = 1024 - traceCount = 100 // Shouldn't trigger payload, but bigger than the disruptor size. - trackType << [TrackType.CITESTCYCLE] - } - - def "test flush by time"() { - setup: - def healthMetrics = Mock(HealthMetrics) - def api = Mock(DDIntakeApi) - def writer = DDIntakeWriter.builder() - .addTrack(trackType, api) - .healthMetrics(healthMetrics) - .monitoring(monitoring) - .flushIntervalMilliseconds(1000) - .alwaysFlush(false) - .build() - writer.start() - def span = dummyTracer.buildSpan("datadog", "fakeOperation").start() - def trace = (1..10).collect { span } - - when: - (1..5).each { - writer.write(trace) - } - phaser.awaitAdvanceInterruptibly(phaser.arriveAndDeregister()) - - then: - 1 * healthMetrics.onSerialize(_) - 1 * api.sendSerializedTraces({ it.traceCount() == 5 }) >> RemoteApi.Response.success(200) - _ * healthMetrics.onPublish(_, _) - 1 * healthMetrics.onSend(_, _, _) >> { - phaser.arrive() - } - 0 * _ - - cleanup: - writer.close() - - where: - trackType << [TrackType.CITESTCYCLE] - } - - @Timeout(30) - def "test default buffer size for #trackType"() { - setup: - def api = Mock(DDIntakeApi) - def writer = DDIntakeWriter.builder() - .addTrack(trackType, api) - .wellKnownTags(wellKnownTags) - .traceBufferSize(BUFFER_SIZE) - .prioritization(ENSURE_TRACE) - .monitoring(monitoring) - .flushIntervalMilliseconds(-1) - .alwaysFlush(false) - .build() - writer.start() - - when: - def discovery = new DDIntakeMapperDiscovery(trackType, wellKnownTags, false) - discovery.discover() - def mapper = (RemoteMapper) discovery.mapper - def traceSize = calculateSize(minimalTrace, mapper) - int maxedPayloadTraceCount = ((int) ((mapper.messageBufferSize()) / traceSize)) - (0..maxedPayloadTraceCount).each { - writer.write(minimalTrace) - } - writer.flush() - - then: - 1 * api.sendSerializedTraces({ it.traceCount() == maxedPayloadTraceCount }) >> RemoteApi.Response.success(200) - 1 * api.sendSerializedTraces({ it.traceCount() == 1 }) >> RemoteApi.Response.success(200) - 0 * _ - - cleanup: - writer.close() - - where: - minimalTrace = createMinimalTrace() - trackType << [TrackType.CITESTCYCLE] - } - - def "check that there are no interactions after close"() { - setup: - def api = Mock(DDIntakeApi) - def healthMetrics = Mock(HealthMetrics) - def writer = DDIntakeWriter.builder() - .addTrack(trackType, api) - .healthMetrics(healthMetrics) - .monitoring(monitoring) - .alwaysFlush(false) - .build() - writer.start() - - when: - writer.close() - writer.write([]) - writer.flush() - - then: - // this will be checked during flushing - 1 * healthMetrics.onFailedPublish(_,_) - 1 * healthMetrics.onFlush(_) - 1 * healthMetrics.onShutdown(_) - 1 * healthMetrics.close() - 0 * _ - - cleanup: - writer.close() - - where: - trackType << [TrackType.CITESTCYCLE] - } - - def "monitor happy path"() { - setup: - def healthMetrics = Mock(HealthMetrics) - def minimalTrace = createMinimalTrace() - def path = buildIntakePath(trackType, apiVersion) - def intake = httpServer { - handlers { - post(path) { - response.status(200).send() - } - } - } - def hostUrl = HttpUrl.get(intake.address) - def client = OkHttpUtils.buildHttpClient(hostUrl, 1000) - def api = DDIntakeApi.builder() - .hostUrl(hostUrl) - .httpClient(client) - .apiKey("my-api-key") - .trackType(trackType) - .build() - def writer = DDIntakeWriter.builder() - .addTrack(trackType, api) - .healthMetrics(healthMetrics) - .monitoring(monitoring) - .alwaysFlush(false) - .build() - - when: - writer.start() - - then: - 1 * healthMetrics.onStart(writer.getCapacity()) - - when: - writer.write(minimalTrace) - writer.flush() - - then: - 1 * healthMetrics.onPublish(minimalTrace, _) - 1 * healthMetrics.onSerialize(_) - 1 * healthMetrics.onFlush(false) - 1 * healthMetrics.onSend(1, _, { response -> response.success() && response.status().present && response.status().asInt == 200 }) - - when: - writer.close() - - then: - 1 * healthMetrics.onShutdown(true) - - cleanup: - intake.close() - - where: - trackType | apiVersion - TrackType.CITESTCYCLE | "v2" - } - - def "monitor intake returns error"() { - setup: - def healthMetrics = Mock(HealthMetrics) - def minimalTrace = createMinimalTrace() - - def path = buildIntakePath(trackType, apiVersion) - def intake = httpServer { - handlers { - post(path) { - response.status(500).send() - } - } - } - def hostUrl = HttpUrl.get(intake.address) - def client = OkHttpUtils.buildHttpClient(hostUrl, 1000) - def api = DDIntakeApi.builder() - .hostUrl(hostUrl) - .httpClient(client) - .apiKey("my-api-key") - .trackType(trackType) - .build() - def writer = DDIntakeWriter.builder() - .addTrack(trackType, api) - .healthMetrics(healthMetrics) - .monitoring(monitoring) - .alwaysFlush(false) - .build() - - when: - writer.start() - - then: - 1 * healthMetrics.onStart(writer.getCapacity()) - - when: - writer.write(minimalTrace) - writer.flush() - - then: - 1 * healthMetrics.onPublish(minimalTrace, _) - 1 * healthMetrics.onSerialize(_) - 1 * healthMetrics.onFlush(false) - 1 * healthMetrics.onFailedSend(1, _, { response -> !response.success() && response.status().present && response.status().asInt == 500 }) - - when: - writer.close() - - then: - 1 * healthMetrics.onShutdown(true) - - cleanup: - intake.close() - - where: - trackType | apiVersion - TrackType.CITESTCYCLE | "v2" - } - - def "unreachable intake test"() { - setup: - def healthMetrics = Mock(HealthMetrics) - def minimalTrace = createMinimalTrace() - - def api = Mock(DDIntakeApi) { - it.sendSerializedTraces(_) >> { - // simulating a communication failure to a server - return RemoteApi.Response.failed(new IOException("comm error")) - } - } - - def writer = DDIntakeWriter.builder() - .addTrack(trackType, api) - .monitoring(monitoring) - .healthMetrics(healthMetrics) - .alwaysFlush(false) - .build() - - when: - writer.start() - - then: - 1 * healthMetrics.onStart(writer.getCapacity()) - - when: - writer.write(minimalTrace) - writer.flush() - - then: - // if we know there's no agent, we'll drop the traces before serialising them - // but we also know that there's nowhere to send health metrics to - 1 * healthMetrics.onPublish(_, _) - 1 * healthMetrics.onFlush(false) - - when: - writer.close() - - then: - 1 * healthMetrics.onShutdown(true) - - where: - trackType << [TrackType.CITESTCYCLE] - } - - @Flaky - // if execution is too slow, the http client timeout may trigger. - def "slow response test"() { - def numWritten = 0 - def numFlushes = new AtomicInteger(0) - def numPublished = new AtomicInteger(0) - def numFailedPublish = new AtomicInteger(0) - def numRequests = new AtomicInteger(0) - def numFailedRequests = new AtomicInteger(0) - - def responseSemaphore = new Semaphore(1) - - setup: - def path = buildIntakePath(trackType, apiVersion) - def intake = httpServer { - handlers { - post(path) { - responseSemaphore.acquire() - try { - response.status(200).send() - } finally { - responseSemaphore.release() - } - } - } - } - // This test focuses just on failed publish, so not verifying every callback - def healthMetrics = Stub(HealthMetrics) { - onPublish(_, _) >> { - numPublished.incrementAndGet() - } - onFailedPublish(_,_) >> { - numFailedPublish.incrementAndGet() - } - onFlush(_) >> { - numFlushes.incrementAndGet() - } - onSend(_, _, _) >> { - numRequests.incrementAndGet() - } - onFailedSend(_, _, _) >> { - numFailedRequests.incrementAndGet() - } - } - - def hostUrl = HttpUrl.get(intake.address) - def client = OkHttpUtils.buildHttpClient(hostUrl, 1000) - def api = DDIntakeApi.builder() - .hostUrl(hostUrl) - .httpClient(client) - .apiKey("my-api-key") - .trackType(trackType) - .build() - def writer = DDIntakeWriter.builder() - .addTrack(trackType, api) - .healthMetrics(healthMetrics) - .traceBufferSize(bufferSize) - .alwaysFlush(false) - .build() - writer.start() - - // gate responses - responseSemaphore.acquire() - - // sanity check coordination mechanism of test - // release to allow response to be generated - responseSemaphore.release() - writer.flush() - - // reacquire semaphore to stall further responses - responseSemaphore.acquire() - - when: - // write a single trace and flush - // with responseSemaphore held, the response is blocked but may still time out - writer.write(minimalTrace) - numWritten += 1 - - then: - numFailedPublish.get() == 0 - numPublished.get() == numWritten - numPublished.get() + numFailedPublish.get() == numWritten - numFlushes.get() == 1 - - when: - // send many traces to fill the sender queue... - // loop until outstanding requests > finished requests - while (writer.traceProcessingWorker.getRemainingCapacity() > 0 || numFailedPublish.get() == 0) { - writer.write(minimalTrace) - numWritten += 1 - } - - then: - numFailedPublish.get() > 0 - numPublished.get() + numFailedPublish.get() == numWritten - - when: - - // with both disruptor & queue full, should reject everything - def expectedRejects = 100 - (1..expectedRejects).each { - writer.write(minimalTrace) - numWritten += 1 - } - - then: - numPublished.get() + numFailedPublish.get() == numWritten - - cleanup: - responseSemaphore.release() - - writer.close() - intake.close() - - where: - bufferSize = 16 - minimalTrace = createMinimalTrace() - trackType | apiVersion - TrackType.CITESTCYCLE | "v2" - } - - def "multi threaded"() { - def numPublished = new AtomicInteger(0) - def numFailedPublish = new AtomicInteger(0) - def numRepSent = new AtomicInteger(0) - - setup: - def minimalTrace = createMinimalTrace() - def path = buildIntakePath(trackType, apiVersion) - def intake = httpServer { - handlers { - post(path) { - response.status(200).send() - } - } - } - // This test focuses just on failed publish, so not verifying every callback - def healthMetrics = Stub(HealthMetrics) { - onPublish(_, _) >> { - numPublished.incrementAndGet() - } - onFailedPublish(_,_) >> { - numFailedPublish.incrementAndGet() - } - onSend(_, _, _) >> { repCount, sizeInBytes, response -> - numRepSent.addAndGet(repCount) - } - } - - def hostUrl = HttpUrl.get(intake.address) - def client = OkHttpUtils.buildHttpClient(hostUrl, 1000) - def api = DDIntakeApi.builder() - .hostUrl(hostUrl) - .httpClient(client) - .apiKey("my-api-key") - .trackType(trackType) - .build() - def writer = DDIntakeWriter.builder() - .addTrack(trackType, api) - .monitoring(monitoring) - .healthMetrics(healthMetrics) - .alwaysFlush(false) - .build() - writer.start() - - when: - def producer = { - (1..100).each { - writer.write(minimalTrace) - } - } as Runnable - - def t1 = new Thread(producer) - t1.start() - - def t2 = new Thread(producer) - t2.start() - - t1.join() - t2.join() - - writer.flush() - - then: - conditions.eventually { - def totalTraces = 100 + 100 - assert numPublished.get() == totalTraces - assert numRepSent.get() == totalTraces - } - - cleanup: - writer.close() - intake.close() - - where: - trackType | apiVersion - TrackType.CITESTCYCLE | "v2" - } - - def "statsd success"() { - def numTracesAccepted = new AtomicInteger(0) - def numRequests = new AtomicInteger(0) - def numResponses = new AtomicInteger(0) - - setup: - def minimalTrace = createMinimalTrace() - def path = buildIntakePath(trackType, apiVersion) - def intake = httpServer { - handlers { - post(path) { - response.status(200).send() - } - } - } - def healthMetrics = Stub(HealthMetrics) - healthMetrics.onPublish(_, _) >> { - numTracesAccepted.incrementAndGet() - } - healthMetrics.onSend(_, _, _) >> { - numRequests.incrementAndGet() - numResponses.incrementAndGet() - } - def hostUrl = HttpUrl.get(intake.address) - def client = OkHttpUtils.buildHttpClient(hostUrl, 1000) - def api = DDIntakeApi.builder() - .hostUrl(hostUrl) - .httpClient(client) - .apiKey("my-api-key") - .trackType(trackType) - .build() - def writer = DDIntakeWriter.builder() - .addTrack(trackType, api) - .monitoring(monitoring) - .healthMetrics(healthMetrics) - .alwaysFlush(false) - .build() - writer.start() - - when: - writer.write(minimalTrace) - writer.flush() - - then: - numTracesAccepted.get() == 1 - numRequests.get() == 1 - numResponses.get() == 1 - - cleanup: - intake.close() - writer.close() - - where: - trackType | apiVersion - TrackType.CITESTCYCLE | "v2" - } - - def "statsd comm failure"() { - setup: - def minimalTrace = createMinimalTrace() - - def api = Mock(DDIntakeApi) - api.sendSerializedTraces(_) >> RemoteApi.Response.failed(new IOException("comm error")) - - def latch = new CountDownLatch(2) - def statsd = Mock(StatsDClient) - def healthMetrics = new TracerHealthMetrics(statsd, 100, TimeUnit.MILLISECONDS) - def writer = DDIntakeWriter.builder() - .addTrack(trackType, api) - .monitoring(monitoring) - .healthMetrics(healthMetrics) - .alwaysFlush(false) - .build() - healthMetrics.start() - writer.start() - - when: - writer.write(minimalTrace) - writer.flush() - latch.await(10, TimeUnit.SECONDS) - - then: - 1 * statsd.count("api.requests.total", 1, _) >> { - latch.countDown() - } - 0 * statsd.incrementCounter("api.responses.total", _) - 1 * statsd.count("api.errors.total", 1, _) >> { - latch.countDown() - } - - cleanup: - writer.close() - healthMetrics.close() - - where: - trackType | apiVersion - TrackType.CITESTCYCLE | "v2" - } - - def createMinimalContext() { - def tracer = Stub(CoreTracer) - def trace = Stub(PendingTrace) - trace.mapServiceName(_) >> { String serviceName -> serviceName } - trace.getTracer() >> tracer - return new DDSpanContext( - DDTraceId.ONE, - 1, - DDSpanId.ZERO, - "", - "", - "", - "", - PrioritySampling.UNSET, - "", - [:], - false, - "", - 0, - trace, - null, - null, - NoopPathwayContext.INSTANCE, - false, - PropagationTags.factory().empty()) - } - - def createMinimalTrace() { - def context = createMinimalContext() - def minimalSpan = new DDSpan("test", 0, context, null) - context.getTraceCollector().getRootSpan() >> minimalSpan - def minimalTrace = [minimalSpan] - - return minimalTrace - } - - static int calculateSize(List trace, RemoteMapper mapper) { - AtomicInteger size = new AtomicInteger() - def packer = new MsgPackWriter(new FlushingBuffer(mapper.messageBufferSize(), new ByteBufferConsumer() { - @Override - void accept(int messageCount, ByteBuffer buffer) { - size.set(buffer.limit() - buffer.position()) - } - })) - packer.format(trace, mapper) - packer.flush() - return size.get() - } - - def buildIntakePath(TrackType trackType, String apiVersion) { - return String.format("/api/%s/%s", apiVersion, trackType.name().toLowerCase()) - } -} diff --git a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/DDIntakeWriterTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/common/writer/DDIntakeWriterTest.groovy deleted file mode 100644 index e0a347841a1..00000000000 --- a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/DDIntakeWriterTest.groovy +++ /dev/null @@ -1,215 +0,0 @@ -package datadog.trace.common.writer - -import datadog.communication.ddagent.DDAgentFeaturesDiscovery -import datadog.trace.api.DDSpanId -import datadog.trace.api.DDTraceId -import datadog.trace.api.intake.TrackType -import datadog.trace.api.sampling.PrioritySampling -import datadog.trace.api.datastreams.NoopPathwayContext -import datadog.metrics.impl.MonitoringImpl -import datadog.metrics.api.statsd.StatsDClient -import datadog.trace.common.writer.ddagent.DDAgentApi -import datadog.trace.common.writer.ddagent.DDAgentMapperDiscovery -import datadog.trace.core.CoreTracer -import datadog.trace.core.DDSpan -import datadog.trace.core.DDSpanContext -import datadog.trace.core.PendingTrace -import datadog.trace.core.monitor.HealthMetrics -import datadog.trace.core.propagation.PropagationTags -import datadog.trace.core.test.DDCoreSpecification -import spock.lang.Subject - -import java.util.concurrent.TimeUnit - -import static datadog.trace.common.writer.ddagent.PrioritizationStrategy.PublishResult.DROPPED_BUFFER_OVERFLOW -import static datadog.trace.common.writer.ddagent.PrioritizationStrategy.PublishResult.DROPPED_BY_POLICY -import static datadog.trace.common.writer.ddagent.PrioritizationStrategy.PublishResult.ENQUEUED_FOR_SERIALIZATION -import static datadog.trace.common.writer.ddagent.PrioritizationStrategy.PublishResult.ENQUEUED_FOR_SINGLE_SPAN_SAMPLING - -class DDIntakeWriterTest extends DDCoreSpecification { - - def healthMetrics = Mock(HealthMetrics) - def worker = Mock(TraceProcessingWorker) - def discovery = Mock(DDAgentFeaturesDiscovery) - def api = Mock(DDAgentApi) - def monitoring = new MonitoringImpl(StatsDClient.NO_OP, 1, TimeUnit.SECONDS) - def dispatcher = new PayloadDispatcherImpl(new DDAgentMapperDiscovery(discovery), api, healthMetrics, monitoring) - - @Subject - def writer = new DDIntakeWriter(worker, dispatcher, healthMetrics, false) - - // Only used to create spans - def dummyTracer = tracerBuilder().writer(new ListWriter()).build() - - def cleanup() { - writer.close() - dummyTracer.close() - } - - def "test writer builder"() { - when: - def writer = DDIntakeWriter.builder().addTrack(TrackType.NOOP, Mock(RemoteApi)).build() - - then: - writer != null - } - - def "test writer.start"() { - when: - writer.start() - - then: - 1 * healthMetrics.start() - 1 * worker.start() - 1 * worker.getCapacity() >> capacity - 1 * healthMetrics.onStart(capacity) - 0 * _ - - where: - capacity = 5 - } - - def "test writer.flush"() { - when: - writer.flush() - - then: - 1 * worker.flush(1, TimeUnit.SECONDS) >> true - 1 * healthMetrics.onFlush(false) - 0 * _ - - when: - writer.flush() - - then: - 1 * worker.flush(1, TimeUnit.SECONDS) >> false - 0 * _ - } - - def "test writer.flush closed"() { - setup: - writer.close() - - when: - writer.flush() - - then: - 0 * _ - } - - def "test writer.write publish succeeds"() { - setup: - def trace = [dummyTracer.buildSpan("datadog", "fakeOperation").start()] - - when: "publish succeeds" - writer.write(trace) - - then: "monitor is notified of successful publication" - 1 * worker.publish(_, _, trace) >> ENQUEUED_FOR_SERIALIZATION - 1 * healthMetrics.onPublish(trace, _) - _ * worker.flush(1, TimeUnit.SECONDS) - 0 * _ - } - - def "test writer.write publish for single span sampling"() { - setup: - def trace = [dummyTracer.buildSpan("datadog", "fakeOperation").start()] - - when: "publish succeeds" - writer.write(trace) - - then: "monitor is notified of successful publication" - 1 * worker.publish(_, _, trace) >> ENQUEUED_FOR_SINGLE_SPAN_SAMPLING - // shouldn't call monitor.onPublish - _ * worker.flush(1, TimeUnit.SECONDS) - 0 * _ - } - - def "test writer.write publish fails"() { - setup: - def trace = [dummyTracer.buildSpan("datadog", "fakeOperation").start()] - - when: "publish fails" - writer.write(trace) - - then: "monitor is notified of unsuccessful publication" - 1 * worker.publish(_, _, trace) >> publishResult - 1 * healthMetrics.onFailedPublish(_,1) - _ * worker.flush(1, TimeUnit.SECONDS) - 0 * _ - - where: - publishResult << [DROPPED_BUFFER_OVERFLOW, DROPPED_BY_POLICY] - } - - def "empty traces should be reported as failures"() { - when: "trace is empty" - writer.write([]) - - then: "monitor is notified of unsuccessful publication" - 1 * healthMetrics.onFailedPublish(_,0) - _ * worker.flush(1, TimeUnit.SECONDS) - 0 * _ - } - - def "test writer.write closed"() { - setup: - writer.close() - def trace = [dummyTracer.buildSpan("datadog", "fakeOperation").start()] - - when: - writer.write(trace) - - then: - 1 * healthMetrics.onFailedPublish(_,1) - _ * worker.flush(1, TimeUnit.SECONDS) - 0 * _ - } - - def "dropped trace is counted"() { - setup: - def dispatcher = Mock(PayloadDispatcherImpl) - def writer = new DDIntakeWriter(worker, dispatcher, healthMetrics, true) - def p0 = newSpan() - p0.setSamplingPriority(PrioritySampling.SAMPLER_DROP) - def trace = [p0, newSpan()] - - when: - writer.write(trace) - - then: - 1 * worker.publish(trace[0], PrioritySampling.SAMPLER_DROP, trace) >> publishResult - 1 * dispatcher.onDroppedTrace(trace.size()) - - where: - publishResult << [DROPPED_BY_POLICY, DROPPED_BUFFER_OVERFLOW] - } - - def newSpan() { - CoreTracer tracer = Stub(CoreTracer) - PendingTrace trace = Stub(PendingTrace) - trace.mapServiceName(_) >> { String serviceName -> serviceName } - trace.getTracer() >> tracer - def context = new DDSpanContext( - DDTraceId.ONE, - 1, - DDSpanId.ZERO, - null, - "", - "", - "", - PrioritySampling.UNSET, - "", - [:], - false, - "", - 0, - trace, - null, - null, - NoopPathwayContext.INSTANCE, - false, - PropagationTags.factory().empty()) - return new DDSpan("test", 0, context, null) - } -} diff --git a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/MultiWriterTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/common/writer/MultiWriterTest.groovy deleted file mode 100644 index 1e406ae9bcb..00000000000 --- a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/MultiWriterTest.groovy +++ /dev/null @@ -1,69 +0,0 @@ -package datadog.trace.common.writer - -import datadog.trace.core.DDSpan -import datadog.trace.test.util.DDSpecification - -class MultiWriterTest extends DDSpecification { - - def "test that multi writer delegates to all"() { - setup: - def writers = new Writer[3] - Writer mockW1 = Mock() - Writer mockW2 = Mock() - writers[0] = mockW1 - // null in position 1 to check that we skip that - writers[2] = mockW2 - def writer = new MultiWriter(writers) - List trace = new LinkedList<>() - - when: - writer.start() - - then: - 1 * mockW1.start() - 1 * mockW2.start() - 0 * _ - - when: - writer.write(trace) - - then: - 1 * mockW1.write({ it == trace }) - 1 * mockW2.write({ it == trace }) - 0 * _ - - when: - def flushed = writer.flush() - - then: - 1 * mockW1.flush() >> true - 1 * mockW2.flush() >> true - 0 * _ - flushed - - when: - def notFlushed = writer.flush() - - then: - 1 * mockW1.flush() >> true - 1 * mockW2.flush() >> false - 0 * _ - !notFlushed - - when: - writer.close() - - then: - 1 * mockW1.close() - 1 * mockW2.close() - 0 * _ - - when: - writer.incrementDropCounts(0) - - then: - 1 * mockW1.incrementDropCounts(0) - 1 * mockW2.incrementDropCounts(0) - 0 * _ - } -} diff --git a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/PayloadDispatcherImplTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/common/writer/PayloadDispatcherImplTest.groovy deleted file mode 100644 index 8aaa47062fa..00000000000 --- a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/PayloadDispatcherImplTest.groovy +++ /dev/null @@ -1,179 +0,0 @@ -package datadog.trace.common.writer - -import datadog.communication.ddagent.DDAgentFeaturesDiscovery -import datadog.metrics.api.statsd.StatsDClient -import datadog.metrics.impl.MonitoringImpl -import datadog.trace.api.DDSpanId -import datadog.trace.api.DDTraceId -import datadog.trace.api.datastreams.NoopPathwayContext -import datadog.trace.api.sampling.PrioritySampling -import datadog.trace.common.writer.ddagent.DDAgentApi -import datadog.trace.common.writer.ddagent.DDAgentMapperDiscovery -import datadog.trace.core.CoreTracer -import datadog.trace.core.DDSpan -import datadog.trace.core.DDSpanContext -import datadog.trace.core.PendingTrace -import datadog.trace.core.monitor.HealthMetrics -import datadog.trace.core.propagation.PropagationTags -import datadog.trace.test.util.DDSpecification -import java.nio.ByteBuffer -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean -import spock.lang.Shared -import spock.lang.Timeout - -class PayloadDispatcherImplTest extends DDSpecification { - - @Shared - MonitoringImpl monitoring = new MonitoringImpl(StatsDClient.NO_OP, 1, TimeUnit.SECONDS) - - @Timeout(10) - def "flush automatically when data limit is breached"() { - setup: - AtomicBoolean flushed = new AtomicBoolean() - HealthMetrics healthMetrics = Stub(HealthMetrics) - DDAgentFeaturesDiscovery discovery = Stub(DDAgentFeaturesDiscovery) - discovery.getTraceEndpoint() >> traceEndpoint - DDAgentApi api = Stub(DDAgentApi) - api.sendSerializedTraces(_) >> { - flushed.set(true) - return RemoteApi.Response.success(200) - } - PayloadDispatcherImpl dispatcher = new PayloadDispatcherImpl(new DDAgentMapperDiscovery(discovery), api, healthMetrics, monitoring) - List trace = [realSpan()] - when: - while (!flushed.get()) { - dispatcher.addTrace(trace) - } - - then: "the dispatcher has flushed" - flushed.get() - - where: - traceEndpoint << ["v0.5/traces", "v0.4/traces"] - } - - def "should flush buffer on demand"() { - setup: - HealthMetrics healthMetrics = Mock(HealthMetrics) - DDAgentFeaturesDiscovery discovery = Mock(DDAgentFeaturesDiscovery) - DDAgentApi api = Mock(DDAgentApi) - PayloadDispatcherImpl dispatcher = new PayloadDispatcherImpl(new DDAgentMapperDiscovery(discovery), api, healthMetrics, monitoring) - List trace = [realSpan()] - when: - for (int i = 0; i < traceCount; ++i) { - dispatcher.addTrace(trace) - } - dispatcher.flush() - then: - 2 * discovery.getTraceEndpoint() >> traceEndpoint - 1 * healthMetrics.onSerialize({ it > 0 }) - 1 * api.sendSerializedTraces({ it.traceCount() == traceCount }) >> RemoteApi.Response.success(200) - - where: - traceEndpoint | traceCount - "v0.4/traces" | 1 - "v0.4/traces" | 10 - "v0.4/traces" | 100 - "v0.5/traces" | 1 - "v0.5/traces" | 10 - "v0.5/traces" | 100 - } - - def "should report failed request to monitor"() { - setup: - HealthMetrics healthMetrics = Mock(HealthMetrics) - DDAgentFeaturesDiscovery discovery = Mock(DDAgentFeaturesDiscovery) - DDAgentApi api = Mock(DDAgentApi) - PayloadDispatcherImpl dispatcher = new PayloadDispatcherImpl(new DDAgentMapperDiscovery(discovery), api, healthMetrics, monitoring) - List trace = [realSpan()] - when: - for (int i = 0; i < traceCount; ++i) { - dispatcher.addTrace(trace) - } - dispatcher.flush() - then: - 2 * discovery.getTraceEndpoint() >> traceEndpoint - 1 * healthMetrics.onSerialize({ it > 0 }) - 1 * api.sendSerializedTraces({ it.traceCount() == traceCount }) >> RemoteApi.Response.failed(400) - - where: - traceEndpoint | traceCount - "v0.4/traces" | 1 - "v0.4/traces" | 10 - "v0.4/traces" | 100 - "v0.5/traces" | 1 - "v0.5/traces" | 10 - "v0.5/traces" | 100 - } - - def "should drop trace when there is no agent connectivity"() { - setup: - HealthMetrics healthMetrics = Mock(HealthMetrics) - DDAgentApi api = Mock(DDAgentApi) - DDAgentFeaturesDiscovery discovery = Mock(DDAgentFeaturesDiscovery) - PayloadDispatcherImpl dispatcher = new PayloadDispatcherImpl(new DDAgentMapperDiscovery(discovery), api, healthMetrics, monitoring) - List trace = [realSpan()] - discovery.getTraceEndpoint() >> null - when: - dispatcher.addTrace(trace) - then: - 1 * healthMetrics.onFailedPublish(PrioritySampling.UNSET,_) - } - - def "trace and span counts are reset after access"() { - setup: - HealthMetrics healthMetrics = Stub(HealthMetrics) - DDAgentApi api = Stub(DDAgentApi) - DDAgentFeaturesDiscovery discovery = Mock(DDAgentFeaturesDiscovery) { - it.getTraceEndpoint() >> "v0.4/traces" - } - PayloadDispatcherImpl dispatcher = new PayloadDispatcherImpl(new DDAgentMapperDiscovery(discovery), api, healthMetrics, monitoring) - - when: - dispatcher.addTrace([]) - dispatcher.onDroppedTrace(20) - dispatcher.onDroppedTrace(2) - Payload payload = dispatcher.newPayload(1, ByteBuffer.allocate(0)) - then: - payload.droppedSpans() == 22 - payload.droppedTraces() == 2 - when: - Payload newPayload = dispatcher.newPayload(1, ByteBuffer.allocate(0)) - then: - newPayload.droppedSpans() == 0 - newPayload.droppedTraces() == 0 - } - - - def realSpan() { - CoreTracer tracer = Stub(CoreTracer) - PendingTrace trace = Stub(PendingTrace) - trace.getTracer() >> tracer - trace.mapServiceName(_) >> { String serviceName -> serviceName } - def context = new DDSpanContext( - DDTraceId.ONE, - 1, - DDSpanId.ZERO, - null, - "", - "", - "", - PrioritySampling.UNSET, - "", - [:], - false, - "", - 0, - trace, - null, - null, - NoopPathwayContext.INSTANCE, - false, - PropagationTags.factory().empty()) - def span = new DDSpan("test", 0, context, null) - // Stub explicitly to avoid Spock fabricating a dummy DDSpan per call (slow reflection fallback). - trace.getRootSpan() >> span - return span - } -} diff --git a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/PrioritizationTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/common/writer/PrioritizationTest.groovy deleted file mode 100644 index 8fa2f08eaa1..00000000000 --- a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/PrioritizationTest.groovy +++ /dev/null @@ -1,258 +0,0 @@ -package datadog.trace.common.writer - -import datadog.trace.common.writer.ddagent.FlushEvent -import datadog.trace.common.writer.ddagent.PrioritizationStrategy -import datadog.trace.common.writer.ddagent.PrioritizationStrategy.PublishResult -import datadog.trace.core.DDSpan -import datadog.trace.test.util.DDSpecification - -import java.util.concurrent.TimeUnit - -import static datadog.trace.api.sampling.PrioritySampling.SAMPLER_DROP -import static datadog.trace.api.sampling.PrioritySampling.SAMPLER_KEEP -import static datadog.trace.api.sampling.PrioritySampling.UNSET -import static datadog.trace.api.sampling.PrioritySampling.USER_KEEP -import static datadog.trace.common.writer.ddagent.Prioritization.ENSURE_TRACE -import static datadog.trace.common.writer.ddagent.Prioritization.FAST_LANE -import static datadog.trace.common.writer.ddagent.PrioritizationStrategy.PublishResult.* - -class PrioritizationTest extends DDSpecification { - - def "ensure trace strategy tries to send kept and unset priority traces to the primary queue until offer(..) is true, dropped traces to the secondary queue"() { - setup: - Queue primary = Mock(Queue) - Queue secondary = Mock(Queue) - PrioritizationStrategy blocking = ENSURE_TRACE.create(primary, secondary, null, { false }) - - when: - PublishResult publishResult = blocking.publish(Mock(DDSpan), priority, trace) - - then: - publishResult == ENQUEUED_FOR_SERIALIZATION - primaryOffers * primary.offer(trace) >> !primaryFull >> true - secondaryOffers * secondary.offer(trace) >> true - - where: - // spotless:off - trace | primaryFull | priority | primaryOffers | secondaryOffers - [] | true | UNSET | 2 | 0 - [] | true | SAMPLER_DROP | 0 | 1 - [] | true | SAMPLER_KEEP | 2 | 0 - [] | true | SAMPLER_DROP | 0 | 1 - [] | true | USER_KEEP | 2 | 0 - [] | false | UNSET | 1 | 0 - [] | false | SAMPLER_DROP | 0 | 1 - [] | false | SAMPLER_KEEP | 1 | 0 - [] | false | SAMPLER_DROP | 0 | 1 - [] | false | USER_KEEP | 1 | 0 - // spotless:on - } - - def "fast lane strategy sends kept and unset priority traces to the primary queue, dropped traces to the secondary queue"() { - setup: - Queue primary = Mock(Queue) - Queue secondary = Mock(Queue) - PrioritizationStrategy fastLane = FAST_LANE.create(primary, secondary, null, { false }) - - when: - PublishResult publishResult = fastLane.publish(Mock(DDSpan), priority, trace) - - then: - publishResult == DROPPED_BUFFER_OVERFLOW - primaryOffers * primary.offer(trace) - secondaryOffers * secondary.offer(trace) - - where: - // spotless:off - trace | priority | primaryOffers | secondaryOffers - [] | UNSET | 1 | 0 - [] | SAMPLER_DROP | 0 | 1 - [] | SAMPLER_KEEP | 1 | 0 - [] | SAMPLER_DROP | 0 | 1 - [] | USER_KEEP | 1 | 0 - // spotless:on - } - - def "FAST_LANE with active dropping policy sends kept and unset priority traces to the primary queue, drops all else"() { - setup: - Queue primary = Mock(Queue) - Queue secondary = Mock(Queue) - PrioritizationStrategy drop = FAST_LANE.create(primary, secondary, null, { true }) - - when: - PublishResult publishResult = drop.publish(Mock(DDSpan), priority, trace) - - then: - publishResult == expectedResult - primaryOffers * primary.offer(trace) >> true - 0 * secondary.offer(trace) - - where: - // spotless:off - trace | priority | primaryOffers | expectedResult - [] | UNSET | 1 | ENQUEUED_FOR_SERIALIZATION - [] | SAMPLER_DROP | 0 | DROPPED_BY_POLICY - [] | SAMPLER_KEEP | 1 | ENQUEUED_FOR_SERIALIZATION - [] | SAMPLER_DROP | 0 | DROPPED_BY_POLICY - [] | USER_KEEP | 1 | ENQUEUED_FOR_SERIALIZATION - // spotless:on - } - - def "#strategy strategy flushes primary queue"() { - setup: - Queue primary = Mock(Queue) - Queue secondary = Mock(Queue) - PrioritizationStrategy fastLane = strategy.create(primary, secondary, null, { false }) - when: - fastLane.flush(100, TimeUnit.MILLISECONDS) - then: - 1 * primary.offer({ it instanceof FlushEvent }) >> true - 0 * secondary.offer(_) - - where: - strategy << [FAST_LANE, ENSURE_TRACE] - } - - def "drop strategy respects force keep" () { - setup: - Queue primary = Mock(Queue) - PrioritizationStrategy drop = strategy.create(primary, null, null, { true }) - DDSpan root = Mock(DDSpan) - List trace = [root] - - when: - PublishResult publishResult = drop.publish(root, SAMPLER_DROP, trace) - - then: - publishResult == expectedResult - 1 * root.isForceKeep() >> forceKeep - (forceKeep ? 1 : 0) * primary.offer(trace) >> true - 0 * _ - - where: - strategy | forceKeep | expectedResult - FAST_LANE | true | ENQUEUED_FOR_SERIALIZATION - FAST_LANE | false | DROPPED_BY_POLICY - } - - def "ensure trace strategy tries to send kept and unset priority traces to the primary queue until offer(..) is true, dropped traces to the span sampling queue"() { - setup: - Queue primary = Mock(Queue) - Queue secondary = Mock(Queue) - Queue spanSampling = Mock(Queue) - PrioritizationStrategy blocking = ENSURE_TRACE.create(primary, secondary, spanSampling, { false }) - - when: - PublishResult publishResult = blocking.publish(Mock(DDSpan), priority, trace) - - then: - publishResult == expectedResult - primaryOffers * primary.offer(trace) >> !primaryFull >> true - 0 * secondary.offer(trace) // expect no traces sent to the secondary queue - singleSpanOffers * spanSampling.offer(trace) >> !singleSpanFull - - where: - trace | primaryFull | priority | primaryOffers | singleSpanOffers | singleSpanFull | expectedResult - [] | true | UNSET | 2 | 0 | false | ENQUEUED_FOR_SERIALIZATION - [] | true | SAMPLER_DROP | 0 | 1 | false | ENQUEUED_FOR_SINGLE_SPAN_SAMPLING - [] | true | SAMPLER_KEEP | 2 | 0 | false | ENQUEUED_FOR_SERIALIZATION - [] | true | SAMPLER_DROP | 0 | 1 | false | ENQUEUED_FOR_SINGLE_SPAN_SAMPLING - [] | true | USER_KEEP | 2 | 0 | false | ENQUEUED_FOR_SERIALIZATION - [] | false | UNSET | 1 | 0 | false | ENQUEUED_FOR_SERIALIZATION - [] | false | SAMPLER_DROP | 0 | 1 | false | ENQUEUED_FOR_SINGLE_SPAN_SAMPLING - [] | false | SAMPLER_KEEP | 1 | 0 | false | ENQUEUED_FOR_SERIALIZATION - [] | false | SAMPLER_DROP | 0 | 1 | false | ENQUEUED_FOR_SINGLE_SPAN_SAMPLING - [] | false | USER_KEEP | 1 | 0 | false | ENQUEUED_FOR_SERIALIZATION - [] | true | UNSET | 2 | 0 | true | ENQUEUED_FOR_SERIALIZATION - [] | true | SAMPLER_DROP | 0 | 1 | true | DROPPED_BUFFER_OVERFLOW - [] | true | SAMPLER_KEEP | 2 | 0 | true | ENQUEUED_FOR_SERIALIZATION - [] | true | SAMPLER_DROP | 0 | 1 | true | DROPPED_BUFFER_OVERFLOW - [] | true | USER_KEEP | 2 | 0 | true | ENQUEUED_FOR_SERIALIZATION - [] | false | UNSET | 1 | 0 | true | ENQUEUED_FOR_SERIALIZATION - [] | false | SAMPLER_DROP | 0 | 1 | true | DROPPED_BUFFER_OVERFLOW - [] | false | SAMPLER_KEEP | 1 | 0 | true | ENQUEUED_FOR_SERIALIZATION - [] | false | SAMPLER_DROP | 0 | 1 | true | DROPPED_BUFFER_OVERFLOW - [] | false | USER_KEEP | 1 | 0 | true | ENQUEUED_FOR_SERIALIZATION - } - - def "fast lane strategy sends kept and unset priority traces to the primary queue, dropped traces to the span sampling queue"() { - setup: - Queue primary = Mock(Queue) - Queue secondary = Mock(Queue) - Queue spanSampling = Mock(Queue) - PrioritizationStrategy fastLane = FAST_LANE.create(primary, secondary, spanSampling, { false }) - - when: - PublishResult publishResult = fastLane.publish(Mock(DDSpan), priority, trace) - - then: - publishResult == expectedResult - primaryOffers * primary.offer(trace) >> true - 0 * secondary.offer(trace) >> true // expect no traces sent to the secondary queue - singleSpanOffers * spanSampling.offer(trace) >> !singleSpanFull - - where: - trace | priority | primaryOffers | singleSpanOffers | singleSpanFull | expectedResult - [] | UNSET | 1 | 0 | false | ENQUEUED_FOR_SERIALIZATION - [] | SAMPLER_DROP | 0 | 1 | false | ENQUEUED_FOR_SINGLE_SPAN_SAMPLING - [] | SAMPLER_KEEP | 1 | 0 | false | ENQUEUED_FOR_SERIALIZATION - [] | SAMPLER_DROP | 0 | 1 | false | ENQUEUED_FOR_SINGLE_SPAN_SAMPLING - [] | USER_KEEP | 1 | 0 | false | ENQUEUED_FOR_SERIALIZATION - [] | UNSET | 1 | 0 | true | ENQUEUED_FOR_SERIALIZATION - [] | SAMPLER_DROP | 0 | 1 | true | DROPPED_BUFFER_OVERFLOW // span sampling queue is full - [] | SAMPLER_KEEP | 1 | 0 | true | ENQUEUED_FOR_SERIALIZATION - [] | SAMPLER_DROP | 0 | 1 | true | DROPPED_BUFFER_OVERFLOW // span sampling queue is full - [] | USER_KEEP | 1 | 0 | true | ENQUEUED_FOR_SERIALIZATION - } - - def "FAST_LANE with active dropping policy sends kept and unset priority traces to the primary queue, send to single span sampling all else"() { - setup: - Queue primary = Mock(Queue) - Queue secondary = Mock(Queue) - Queue spanSampling = Mock(Queue) - PrioritizationStrategy drop = FAST_LANE.create(primary, secondary, spanSampling, { true }) - - when: - PublishResult publishResult = drop.publish(Mock(DDSpan), priority, trace) - - then: - publishResult == expectedResult - primaryOffers * primary.offer(trace) >> true - 0 * secondary.offer(trace) - singleSpanOffers * spanSampling.offer(trace) >> true - - where: - trace | priority | primaryOffers | singleSpanOffers | expectedResult - [] | UNSET | 1 | 0 | ENQUEUED_FOR_SERIALIZATION - [] | SAMPLER_DROP | 0 | 1 | ENQUEUED_FOR_SINGLE_SPAN_SAMPLING - [] | SAMPLER_KEEP | 1 | 0 | ENQUEUED_FOR_SERIALIZATION - [] | SAMPLER_DROP | 0 | 1 | ENQUEUED_FOR_SINGLE_SPAN_SAMPLING - [] | USER_KEEP | 1 | 0 | ENQUEUED_FOR_SERIALIZATION - } - - def "span sampling drop strategy respects force keep" () { - setup: - Queue primary = Mock(Queue) - Queue spanSampling = Mock(Queue) - PrioritizationStrategy drop = strategy.create(primary, null, spanSampling, { true }) - DDSpan root = Mock(DDSpan) - List trace = [root] - - when: - PublishResult publishResult = drop.publish(root, SAMPLER_DROP, trace) - - then: - publishResult == expectedResult - 1 * root.isForceKeep() >> forceKeep - (forceKeep ? 1 : 0) * primary.offer(trace) >> true - (forceKeep ? 0 : 1) * spanSampling.offer(trace) >> !singleSpanFull - 0 * _ - - where: - strategy | forceKeep | singleSpanFull | expectedResult - FAST_LANE | true | true | ENQUEUED_FOR_SERIALIZATION - FAST_LANE | false | true | DROPPED_BUFFER_OVERFLOW - FAST_LANE | true | false | ENQUEUED_FOR_SERIALIZATION - FAST_LANE | false | false | ENQUEUED_FOR_SINGLE_SPAN_SAMPLING - } -} diff --git a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/SerializationTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/common/writer/SerializationTest.groovy deleted file mode 100644 index ba273ca3e16..00000000000 --- a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/SerializationTest.groovy +++ /dev/null @@ -1,49 +0,0 @@ -package datadog.trace.common.writer - -import com.fasterxml.jackson.core.type.TypeReference -import com.fasterxml.jackson.databind.ObjectMapper -import datadog.trace.test.util.DDSpecification -import org.msgpack.core.MessagePack -import org.msgpack.jackson.dataformat.MessagePackFactory - -import static java.util.Collections.singletonMap - -class SerializationTest extends DDSpecification { - def "test json mapper serialization"() { - setup: - def mapper = new ObjectMapper() - def map = ["key1": "val1"] - def serializedMap = mapper.writeValueAsBytes(map) - def serializedList = "[${new String(serializedMap)}]".getBytes() - - when: - def result = mapper.readValue(serializedList, new TypeReference>>() {}) - - then: - result == [map] - new String(serializedList) == '[{"key1":"val1"}]' - } - - def "test msgpack mapper serialization"() { - setup: - def mapper = new ObjectMapper(new MessagePackFactory()) - // GStrings get odd results in the serializer. - def input = (1..1).collect { singletonMap("key$it".toString(), "val$it".toString()) } - def serializedMaps = input.collect { - mapper.writeValueAsBytes(it) - } - - def packer = MessagePack.newDefaultBufferPacker() - packer.packArrayHeader(serializedMaps.size()) - serializedMaps.each { - packer.writePayload(it) - } - def serializedList = packer.toByteArray() - - when: - def result = mapper.readValue(serializedList, new TypeReference>>() {}) - - then: - result == input - } -} diff --git a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/SpanSamplingWorkerTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/common/writer/SpanSamplingWorkerTest.groovy deleted file mode 100644 index fcd16d08efd..00000000000 --- a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/SpanSamplingWorkerTest.groovy +++ /dev/null @@ -1,397 +0,0 @@ -package datadog.trace.common.writer - -import datadog.trace.api.sampling.PrioritySampling -import datadog.trace.common.sampling.SingleSpanSampler -import datadog.trace.core.DDSpan -import datadog.trace.core.monitor.HealthMetrics -import datadog.trace.test.util.DDSpecification -import static SpanSamplingWorker.DefaultSpanSamplingWorker - -import java.util.concurrent.CountDownLatch -import java.util.concurrent.LinkedBlockingDeque -import java.util.concurrent.TimeUnit - -class SpanSamplingWorkerTest extends DDSpecification { - - def "send only sampled spans to the sampled span queue"() { - setup: - Queue primaryQueue = new LinkedBlockingDeque<>(10) - Queue secondaryQueue = new LinkedBlockingDeque<>(10) - def droppingPolicy = { false } - SingleSpanSampler singleSpanSampler = Mock(SingleSpanSampler) - HealthMetrics healthMetrics = Mock(HealthMetrics) - SpanSamplingWorker worker = SpanSamplingWorker.build(10, primaryQueue, secondaryQueue, singleSpanSampler, healthMetrics, droppingPolicy) - worker.start() - DDSpan span1 = Mock(DDSpan) - DDSpan span2 = Mock(DDSpan) - DDSpan span3 = Mock(DDSpan) - singleSpanSampler.setSamplingPriority(span1) >> true - singleSpanSampler.setSamplingPriority(span2) >> false - singleSpanSampler.setSamplingPriority(span3) >> true - - when: - worker.getSpanSamplingQueue().offer([span1, span2, span3]) - - then: - primaryQueue.take() == [span1, span3] - secondaryQueue.take() == [span2] - - cleanup: - worker.close() - } - - def "handle multiple traces"() { - setup: - Queue primaryQueue = new LinkedBlockingDeque<>(10) - Queue secondaryQueue = new LinkedBlockingDeque<>(10) - def droppingPolicy = { false } - SingleSpanSampler singleSpanSampler = Mock(SingleSpanSampler) - HealthMetrics healthMetrics = Mock(HealthMetrics) - SpanSamplingWorker worker = SpanSamplingWorker.build(10, primaryQueue, secondaryQueue, singleSpanSampler, healthMetrics, droppingPolicy) - worker.start() - - DDSpan span1 = Mock(DDSpan) - DDSpan span2 = Mock(DDSpan) - DDSpan span3 = Mock(DDSpan) - singleSpanSampler.setSamplingPriority(span1) >> true - singleSpanSampler.setSamplingPriority(span2) >> false - singleSpanSampler.setSamplingPriority(span3) >> true - - DDSpan span4 = Mock(DDSpan) - DDSpan span5 = Mock(DDSpan) - singleSpanSampler.setSamplingPriority(span4) >> true - singleSpanSampler.setSamplingPriority(span5) >> false - - when: - worker.getSpanSamplingQueue().offer([span1, span2, span3]) - worker.getSpanSamplingQueue().offer([span4, span5]) - - then: - primaryQueue.take() == [span1, span3] - secondaryQueue.take() == [span2] - primaryQueue.take() == [span4] - secondaryQueue.take() == [span5] - - cleanup: - worker.close() - } - - def "skip traces with no sampled spans"() { - setup: - Queue primaryQueue = new LinkedBlockingDeque<>(10) - Queue secondaryQueue = new LinkedBlockingDeque<>(10) - def droppingPolicy = { false } - SingleSpanSampler singleSpanSampler = Mock(SingleSpanSampler) - HealthMetrics healthMetrics = Mock(HealthMetrics) - SpanSamplingWorker worker = SpanSamplingWorker.build(10, primaryQueue, secondaryQueue, singleSpanSampler, healthMetrics, droppingPolicy) - worker.start() - - DDSpan span1 = Mock(DDSpan) - DDSpan span2 = Mock(DDSpan) - DDSpan span3 = Mock(DDSpan) - singleSpanSampler.setSamplingPriority(span1) >> true - singleSpanSampler.setSamplingPriority(span2) >> false - singleSpanSampler.setSamplingPriority(span3) >> true - - DDSpan span4 = Mock(DDSpan) - DDSpan span5 = Mock(DDSpan) - singleSpanSampler.setSamplingPriority(span4) >> false - singleSpanSampler.setSamplingPriority(span5) >> false - - DDSpan span6 = Mock(DDSpan) - DDSpan span7 = Mock(DDSpan) - singleSpanSampler.setSamplingPriority(span6) >> true - singleSpanSampler.setSamplingPriority(span7) >> true - - when: - assert worker.getSpanSamplingQueue().offer([span1, span2, span3]) - assert worker.getSpanSamplingQueue().offer([span4, span5]) - assert worker.getSpanSamplingQueue().offer([span6, span7]) - - then: - primaryQueue.take() == [span1, span3] - secondaryQueue.take() == [span2] - secondaryQueue.take() == [span4, span5] - primaryQueue.take() == [span6, span7] - - cleanup: - worker.close() - } - - def "ignore empty traces"() { - setup: - Queue primaryQueue = new LinkedBlockingDeque<>(10) - Queue secondaryQueue = new LinkedBlockingDeque<>(10) - def droppingPolicy = { false } - SingleSpanSampler singleSpanSampler = Mock(SingleSpanSampler) - HealthMetrics healthMetrics = Mock(HealthMetrics) - SpanSamplingWorker worker = SpanSamplingWorker.build(10, primaryQueue, secondaryQueue, singleSpanSampler, healthMetrics, droppingPolicy) - worker.start() - - DDSpan span1 = Mock(DDSpan) - singleSpanSampler.setSamplingPriority(span1) >> true - - when: - assert worker.getSpanSamplingQueue().offer([]) - assert worker.getSpanSamplingQueue().offer([span1]) - - then: - primaryQueue.take() == [span1] - assert secondaryQueue.isEmpty() - - cleanup: - worker.close() - } - - def "update dropped traces metric when no tracer's spans have been sampled"() { - setup: - Queue primaryQueue = new LinkedBlockingDeque<>(10) - Queue secondaryQueue = new LinkedBlockingDeque<>(10) - def droppingPolicy = { false } - SingleSpanSampler singleSpanSampler = Mock(SingleSpanSampler) - HealthMetrics healthMetrics = Mock(HealthMetrics) - int expectedTraces = 1 - CountDownLatch latch = new CountDownLatch(expectedTraces) - SpanSamplingWorker worker = new DefaultSpanSamplingWorker(10, primaryQueue, secondaryQueue, singleSpanSampler, healthMetrics, droppingPolicy) { - @Override - protected void afterOnEvent() { - latch.countDown() - } - } - worker.start() - - DDSpan span1 = Mock(DDSpan) - DDSpan span2 = Mock(DDSpan) - span1.samplingPriority() >> PrioritySampling.USER_DROP - singleSpanSampler.setSamplingPriority(span1) >> false - singleSpanSampler.setSamplingPriority(span2) >> false - - - def queue = worker.getSpanSamplingQueue() - - when: - assert queue.offer([span1, span2]) - - then: - latch.await(10, TimeUnit.SECONDS) - - then: - assert primaryQueue.isEmpty() - secondaryQueue.take() == [span1, span2] - - then: - 1 * healthMetrics.onPublish([span1, span2], PrioritySampling.USER_DROP) - 0 * healthMetrics.onFailedPublish(_,_) - 0 * healthMetrics.onPartialPublish(_) - - cleanup: - worker.close() - } - - def "update dropped traces metric when primaryQueue is full"() { - setup: - Queue primaryQueue = new LinkedBlockingDeque<>(1) - Queue secondaryQueue = new LinkedBlockingDeque<>(10) - def droppingPolicy = { false } - primaryQueue.offer([]) // occupy the entire queue - SingleSpanSampler singleSpanSampler = Mock(SingleSpanSampler) - HealthMetrics healthMetrics = Mock(HealthMetrics) - int expectedTraces = 1 - CountDownLatch latch = new CountDownLatch(expectedTraces) - SpanSamplingWorker worker = new DefaultSpanSamplingWorker(10, primaryQueue, secondaryQueue, singleSpanSampler, healthMetrics, droppingPolicy) { - @Override - protected void afterOnEvent() { - latch.countDown() - } - } - worker.start() - - DDSpan span1 = Mock(DDSpan) - DDSpan span2 = Mock(DDSpan) - span1.samplingPriority() >> PrioritySampling.SAMPLER_DROP - singleSpanSampler.setSamplingPriority(span1) >> false - singleSpanSampler.setSamplingPriority(span2) >> true - - - def queue = worker.getSpanSamplingQueue() - - when: - assert queue.offer([span1, span2]) - - then: - latch.await(10, TimeUnit.SECONDS) - - then: - assert secondaryQueue.isEmpty() - - then: - 1 * healthMetrics.onFailedPublish(PrioritySampling.SAMPLER_DROP,_) - 0 * healthMetrics.onPublish(_, _) - 0 * healthMetrics.onPartialPublish(_) - - cleanup: - worker.close() - } - - def "update published traces metric when all trace's spans have been sampled"() { - setup: - Queue primaryQueue = new LinkedBlockingDeque<>(10) - Queue secondaryQueue = new LinkedBlockingDeque<>(10) - def droppingPolicy = { false } - SingleSpanSampler singleSpanSampler = Mock(SingleSpanSampler) - HealthMetrics healthMetrics = Mock(HealthMetrics) - SpanSamplingWorker worker = SpanSamplingWorker.build(10, primaryQueue, secondaryQueue, singleSpanSampler, healthMetrics, droppingPolicy) - worker.start() - - DDSpan span1 = Mock(DDSpan) - DDSpan span2 = Mock(DDSpan) - span1.samplingPriority() >> PrioritySampling.SAMPLER_DROP - singleSpanSampler.setSamplingPriority(span1) >> true - singleSpanSampler.setSamplingPriority(span2) >> true - - def queue = worker.getSpanSamplingQueue() - - when: - assert queue.offer([span1, span2]) - - then: - primaryQueue.take() == [span1, span2] - assert secondaryQueue.isEmpty() - - then: - 1 * healthMetrics.onPublish([span1, span2], PrioritySampling.SAMPLER_DROP) - 0 * healthMetrics.onFailedPublish(_,_) - 0 * healthMetrics.onPartialPublish(_) - - cleanup: - worker.close() - } - - def "update partial traces metric when some trace's spans have been dropped and sent to secondaryQueue"() { - setup: - Queue primaryQueue = new LinkedBlockingDeque<>(10) - Queue secondaryQueue = new LinkedBlockingDeque<>(10) - def droppingPolicy = { false } - SingleSpanSampler singleSpanSampler = Mock(SingleSpanSampler) - HealthMetrics healthMetrics = Mock(HealthMetrics) - SpanSamplingWorker worker = SpanSamplingWorker.build(10, primaryQueue, secondaryQueue, singleSpanSampler, healthMetrics, droppingPolicy) - worker.start() - - DDSpan span1 = Mock(DDSpan) - DDSpan span2 = Mock(DDSpan) - DDSpan span3 = Mock(DDSpan) - singleSpanSampler.setSamplingPriority(span1) >> false - singleSpanSampler.setSamplingPriority(span2) >> true - singleSpanSampler.setSamplingPriority(span3) >> false - - def queue = worker.getSpanSamplingQueue() - - when: - assert queue.offer([span1, span2, span3]) - - then: - primaryQueue.take() == [span2] - secondaryQueue.take() == [span1, span3] - - then: - 1 * healthMetrics.onPublish([span1, span2, span3], PrioritySampling.SAMPLER_DROP) - 0 * healthMetrics.onPartialPublish(_) - 0 * healthMetrics.onFailedPublish(_,_) - - cleanup: - worker.close() - } - - def "update partial traces metric when some trace's spans have been dropped and secondaryQueue is full or droppingPolicy is active"() { - setup: - Queue primaryQueue = new LinkedBlockingDeque<>(10) - Queue secondaryQueue = new LinkedBlockingDeque<>(secondaryQueueIsFull ? 1 : 10) - if (secondaryQueueIsFull) { - // occupy the entire queue - secondaryQueue.offer([]) - } - SingleSpanSampler singleSpanSampler = Mock(SingleSpanSampler) - HealthMetrics healthMetrics = Mock(HealthMetrics) - SpanSamplingWorker worker = new DefaultSpanSamplingWorker(10, primaryQueue, secondaryQueue, singleSpanSampler, healthMetrics, { droppingPolicy }) - worker.start() - - DDSpan span1 = Mock(DDSpan) - DDSpan span2 = Mock(DDSpan) - DDSpan span3 = Mock(DDSpan) - span1.samplingPriority() >> PrioritySampling.SAMPLER_DROP - singleSpanSampler.setSamplingPriority(span1) >> false - singleSpanSampler.setSamplingPriority(span2) >> true - singleSpanSampler.setSamplingPriority(span3) >> false - - def queue = worker.getSpanSamplingQueue() - - when: - assert queue.offer([span1, span2, span3]) - - then: - primaryQueue.take() == [span2] - - then: - 1 * healthMetrics.onPartialPublish(2) - 0 * healthMetrics.onFailedPublish(_,_) - 0 * healthMetrics.onPublish(_, _) - - cleanup: - worker.close() - - where: - droppingPolicy | secondaryQueueIsFull - true | false - false | true - true | true - } - - def "update FailedPublish metric when all trace's spans have been dropped and secondaryQueue is full or droppingPolicy is active"() { - setup: - Queue primaryQueue = new LinkedBlockingDeque<>(10) - Queue secondaryQueue = new LinkedBlockingDeque<>(secondaryQueueIsFull ? 1 : 10) - if (secondaryQueueIsFull) { - // occupy the entire queue - secondaryQueue.offer([]) - } - SingleSpanSampler singleSpanSampler = Mock(SingleSpanSampler) - HealthMetrics healthMetrics = Mock(HealthMetrics) - int expectedTraces = 1 - CountDownLatch latch = new CountDownLatch(expectedTraces) - SpanSamplingWorker worker = new DefaultSpanSamplingWorker(10, primaryQueue, secondaryQueue, singleSpanSampler, healthMetrics, { droppingPolicy }) { - @Override - protected void afterOnEvent() { - latch.countDown() - } - } - worker.start() - - DDSpan span1 = Mock(DDSpan) - DDSpan span2 = Mock(DDSpan) - span1.samplingPriority() >> PrioritySampling.SAMPLER_DROP - singleSpanSampler.setSamplingPriority(span1) >> false - singleSpanSampler.setSamplingPriority(span2) >> false - - def queue = worker.getSpanSamplingQueue() - - when: - assert queue.offer([span1, span2]) - - then: - latch.await(10, TimeUnit.SECONDS) - - then: - 1 * healthMetrics.onFailedPublish(PrioritySampling.SAMPLER_DROP,_) - 0 * healthMetrics.onPartialPublish(_) - 0 * healthMetrics.onPublish(_, _) - - cleanup: - worker.close() - - where: - droppingPolicy | secondaryQueueIsFull - true | false - false | true - true | true - } -} diff --git a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/TraceMapperTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/common/writer/TraceMapperTest.groovy deleted file mode 100644 index 6f2173e8374..00000000000 --- a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/TraceMapperTest.groovy +++ /dev/null @@ -1,101 +0,0 @@ -package datadog.trace.common.writer - -import datadog.trace.common.writer.ddagent.TraceMapper -import datadog.trace.common.writer.ddagent.TraceMapperV0_5 -import datadog.communication.serialization.ByteBufferConsumer -import datadog.communication.serialization.FlushingBuffer -import datadog.communication.serialization.msgpack.MsgPackWriter -import datadog.trace.core.DDSpan -import datadog.trace.core.test.DDCoreSpecification -import org.msgpack.core.MessagePack -import org.msgpack.core.MessageUnpacker - -import java.nio.ByteBuffer - -class TraceMapperTest extends DDCoreSpecification { - - def "test trace mapper v0.5"() { - setup: - def tracer = tracerBuilder().writer(new ListWriter()).build() - DDSpan span = (DDSpan) tracer.buildSpan("datadog", null).withTag("service.name", "my-service") - .withTag("elasticsearch.version", "7.0").start() - span.setBaggageItem("baggage", "item") - span.spanContext().setDataTop("mydata", "[1,2,3]") - def trace = [span] - - when: - TraceMapper traceMapper = new TraceMapperV0_5() - CapturingByteBufferConsumer sink = new CapturingByteBufferConsumer() - MsgPackWriter packer = new MsgPackWriter(new FlushingBuffer(1024, sink)) - packer.format(trace, traceMapper) - packer.flush() - - then: - sink.captured != null - ByteBuffer dictionaryBytes = traceMapper.dictionary.slice() - Map meta = new HashMap<>() - - MessageUnpacker dictionaryUnpacker = MessagePack.newDefaultUnpacker(dictionaryBytes) - int dictionaryLength = traceMapper.encoding.size() - String[] dictionary = new String[dictionaryLength] - for (int i = 0; i < dictionary.length; ++i) { - dictionary[i] = dictionaryUnpacker.unpackString() - } - MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(sink.captured) - int traceCount = unpacker.unpackArrayHeader() - traceCount == 1 - for (int i = 0; i < traceCount; ++i) { - int arrayLength = unpacker.unpackArrayHeader() - arrayLength == 12 - String serviceName = dictionary[unpacker.unpackInt()] - serviceName == "my-service" - String operationName = dictionary[unpacker.unpackInt()] // operation name null - operationName == null - String resourceName = dictionary[unpacker.unpackInt()] - resourceName != null - long traceId = unpacker.unpackLong() - traceId == 1 - long spanId = unpacker.unpackLong() - spanId == 1 - long parentId = unpacker.unpackLong() - parentId == 0 - long start = unpacker.unpackLong() - start > 0 - long duration = unpacker.unpackLong() - duration >= 0 - int error = unpacker.unpackInt() - error == 0 - int metaHeader = unpacker.unpackMapHeader() - for (int j = 0; j < metaHeader; ++j) { - String key = dictionary[unpacker.unpackInt()] - key != null - String value = dictionary[unpacker.unpackInt()] - value != null - meta.put(key, value) - } - int metricsHeader = unpacker.unpackMapHeader() - for (int j = 0; j < metricsHeader; ++j) { - String key = dictionary[unpacker.unpackInt()] - key != null - unpacker.skipValue() - } - String type = dictionary[unpacker.unpackInt()] - type != null - - meta.findResult {it.getKey().contains('.mydata.') ? it.getValue() : null } == '[1,2,3]' - } - - cleanup: - tracer.close() - } - - static class CapturingByteBufferConsumer implements ByteBufferConsumer { - - ByteBuffer captured - - @Override - void accept(int messageCount, ByteBuffer buffer) { - captured = buffer - } - } -} diff --git a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/TraceProcessingWorkerTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/common/writer/TraceProcessingWorkerTest.groovy deleted file mode 100644 index e6b076dcfa8..00000000000 --- a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/TraceProcessingWorkerTest.groovy +++ /dev/null @@ -1,430 +0,0 @@ -package datadog.trace.common.writer - -import datadog.trace.common.sampling.SingleSpanSampler -import datadog.trace.common.writer.ddagent.PrioritizationStrategy.PublishResult -import datadog.trace.core.CoreSpan -import datadog.trace.core.DDSpan -import datadog.trace.core.DDSpanContext -import datadog.trace.core.PendingTrace -import datadog.trace.core.monitor.HealthMetrics -import datadog.trace.bootstrap.instrumentation.api.SpanPostProcessor -import datadog.trace.test.util.DDSpecification -import spock.util.concurrent.PollingConditions - -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicInteger - -import static datadog.trace.api.sampling.PrioritySampling.SAMPLER_DROP -import static datadog.trace.api.sampling.PrioritySampling.SAMPLER_KEEP -import static datadog.trace.api.sampling.PrioritySampling.UNSET -import static datadog.trace.api.sampling.PrioritySampling.USER_DROP -import static datadog.trace.api.sampling.PrioritySampling.USER_KEEP -import static datadog.trace.common.writer.ddagent.Prioritization.FAST_LANE -import static datadog.trace.common.writer.ddagent.PrioritizationStrategy.PublishResult.ENQUEUED_FOR_SERIALIZATION - -class TraceProcessingWorkerTest extends DDSpecification { - - def conditions = new PollingConditions(timeout: 5, initialDelay: 0, factor: 1.25) - - def flushCountingPayloadDispatcher(AtomicInteger flushCounter) { - PayloadDispatcherImpl dispatcher = Mock(PayloadDispatcherImpl) - dispatcher.flush() >> { - flushCounter.incrementAndGet() - } - return dispatcher - } - - def "heartbeats should be triggered automatically when enabled"() { - setup: - AtomicInteger flushCount = new AtomicInteger() - TraceProcessingWorker worker = new TraceProcessingWorker(10, Stub(HealthMetrics), - flushCountingPayloadDispatcher(flushCount), { - false - }, - FAST_LANE, - 1, - TimeUnit.NANOSECONDS, - null - ) // stop heartbeats from being throttled - - when: "processor is started" - worker.start() - - then: "heartbeat occurs automatically" - conditions.eventually { - assert flushCount.get() > 0 - } - - cleanup: - worker.close() - } - - def "heartbeats should occur at least once per second when not throttled"() { - setup: - AtomicInteger flushCount = new AtomicInteger() - TraceProcessingWorker worker = new TraceProcessingWorker(10, Stub(HealthMetrics), - flushCountingPayloadDispatcher(flushCount), { - false - }, - FAST_LANE, - 1, - TimeUnit.NANOSECONDS, - null - ) // stop heartbeats from being throttled - def timeConditions = new PollingConditions(timeout: 1, initialDelay: 1, factor: 1.25) - - when: "processor is started" - worker.start() - - then: "heartbeat occurs automatically approximately once per second" - timeConditions.eventually { - assert flushCount.get() > 1 - } - - cleanup: - worker.close() - } - - def "a flush should clear the primary queue"() { - setup: - AtomicInteger flushCount = new AtomicInteger() - TraceProcessingWorker worker = new TraceProcessingWorker(10, Stub(HealthMetrics), - flushCountingPayloadDispatcher(flushCount), { - false - }, - FAST_LANE, - 100, TimeUnit.SECONDS, null) // prevent heartbeats from helping the flush happen - - when: "there is pending work it is completed before a flush" - // processing this span will throw an exception, but it should be caught - // and not disrupt the flush - worker.primaryQueue.offer([Mock(DDSpan)]) - worker.start() - boolean flushed = worker.flush(10, TimeUnit.SECONDS) - - then: "the flush succeeds, triggers a dispatch, and the queue is empty" - flushed - flushCount.get() == 1 - worker.primaryQueue.isEmpty() - - cleanup: - worker.close() - } - - def "should report failure if serialization fails"() { - setup: - Throwable theError = new IllegalStateException("thrown by test") - PayloadDispatcherImpl throwingDispatcher = Mock(PayloadDispatcherImpl) - throwingDispatcher.addTrace(_) >> { - throw theError - } - AtomicInteger errorReported = new AtomicInteger() - HealthMetrics healthMetrics = Mock(HealthMetrics) - healthMetrics.onFailedSerialize(_, theError) >> { - // do this manually with a counter, despite spock's - // lovely syntactical sugar so we don't have a race - // condition induced flaky test. All we care about - // is that an error was reported and that it was the - // right one - errorReported.incrementAndGet() - } - TraceProcessingWorker worker = new TraceProcessingWorker(10, healthMetrics, - throwingDispatcher, { - false - }, FAST_LANE, - 100, TimeUnit.SECONDS, null) // prevent heartbeats from helping the flush happen - worker.start() - - when: "a trace is processed but can't be passed on" - worker.publish(Mock(DDSpan), priority, [Mock(DDSpan)]) - - then: "the error is reported to the monitor" - conditions.eventually { - assert 1 == errorReported.get() - } - - cleanup: - worker.close() - - where: - priority << [SAMPLER_DROP, USER_DROP, SAMPLER_KEEP, USER_KEEP, UNSET] - } - - def "trace should be post-processed"() { - setup: - AtomicInteger acceptedCount = new AtomicInteger() - PayloadDispatcherImpl countingDispatcher = Mock(PayloadDispatcherImpl) - countingDispatcher.addTrace(_) >> { - acceptedCount.getAndIncrement() - } - HealthMetrics healthMetrics = Mock(HealthMetrics) - - def span1 = DDSpan.create("test", 0, Mock(DDSpanContext) { - getTraceCollector() >> Mock(PendingTrace) { - getCurrentTimeNano() >> 0 - } - }, []) - def processedSpan1 = false - - // Span 2 - should NOT be post-processed - def span2 = DDSpan.create("test", 0, Mock(DDSpanContext) { - getTraceCollector() >> Mock(PendingTrace) { - getCurrentTimeNano() >> 0 - } - }, []) - def processedSpan2 = false - - SpanPostProcessor.Holder.INSTANCE = Mock(SpanPostProcessor) { - process(span1, _) >> { processedSpan1 = true } - process(span2, _) >> { processedSpan2 = true } - } - - TraceProcessingWorker worker = new TraceProcessingWorker(10, healthMetrics, - countingDispatcher, { - false - }, FAST_LANE, 100, TimeUnit.SECONDS, null) - worker.start() - - when: "traces are submitted" - worker.publish(span1, SAMPLER_KEEP, [span1, span2]) - worker.publish(span2, SAMPLER_KEEP, [span1, span2]) - - then: "traces are passed through unless rejected on submission" - conditions.eventually { - assert processedSpan1 - assert processedSpan2 - } - - cleanup: - SpanPostProcessor.Holder.INSTANCE = SpanPostProcessor.Holder.NOOP - worker.close() - } - - def "traces should be processed"() { - setup: - AtomicInteger acceptedCount = new AtomicInteger() - PayloadDispatcherImpl countingDispatcher = Mock(PayloadDispatcherImpl) - countingDispatcher.addTrace(_) >> { - acceptedCount.getAndIncrement() - } - HealthMetrics healthMetrics = Mock(HealthMetrics) - TraceProcessingWorker worker = new TraceProcessingWorker(10, healthMetrics, - countingDispatcher, { - false - }, FAST_LANE, 100, TimeUnit.SECONDS, null) - // prevent heartbeats from helping the flush happen - worker.start() - - when: "traces are submitted" - int submitted = 0 - for (int i = 0; i < traceCount; ++i) { - PublishResult publishResult = worker.publish(Mock(DDSpan), priority, [Mock(DDSpan)]) - submitted += publishResult == ENQUEUED_FOR_SERIALIZATION ? 1 : 0 - } - - then: "traces are passed through unless rejected on submission" - 0 * healthMetrics.onFailedSerialize(_, _) - conditions.eventually { - assert submitted == acceptedCount.get() - } - - cleanup: - worker.close() - - where: - priority | traceCount | strategy - SAMPLER_DROP | 1 | FAST_LANE - USER_DROP | 1 | FAST_LANE - SAMPLER_KEEP | 1 | FAST_LANE - USER_KEEP | 1 | FAST_LANE - UNSET | 1 | FAST_LANE - SAMPLER_DROP | 10 | FAST_LANE - USER_DROP | 10 | FAST_LANE - SAMPLER_KEEP | 10 | FAST_LANE - USER_KEEP | 10 | FAST_LANE - UNSET | 10 | FAST_LANE - SAMPLER_DROP | 20 | FAST_LANE - USER_DROP | 20 | FAST_LANE - SAMPLER_KEEP | 20 | FAST_LANE - USER_KEEP | 20 | FAST_LANE - UNSET | 20 | FAST_LANE - SAMPLER_DROP | 100 | FAST_LANE - USER_DROP | 100 | FAST_LANE - SAMPLER_KEEP | 100 | FAST_LANE - USER_KEEP | 100 | FAST_LANE - UNSET | 100 | FAST_LANE - } - - def "flush of full queue after worker thread stopped will not flush but will return"() { - setup: - PayloadDispatcherImpl countingDispatcher = Mock(PayloadDispatcherImpl) - HealthMetrics healthMetrics = Mock(HealthMetrics) - TraceProcessingWorker worker = new TraceProcessingWorker(10, healthMetrics, - countingDispatcher, { - false - }, FAST_LANE, 100, TimeUnit.SECONDS, null) - worker.start() - worker.close() - int queueSize = 0 - while (worker.primaryQueue.offer([Mock(DDSpan)])) { - queueSize++ - } - - when: - boolean flushed = worker.flush(1, TimeUnit.SECONDS) - then: - !flushed - } - - def "send unsampled traces to the SpanProcessingWorker and expect only sampled spans dispatched when dropping policy is active"() { - setup: - HealthMetrics healthMetrics = Mock(HealthMetrics) - AtomicInteger acceptedCount = new AtomicInteger() - AtomicInteger acceptedSpanCount = new AtomicInteger() - PayloadDispatcherImpl countingDispatcher = Mock(PayloadDispatcherImpl) - countingDispatcher.addTrace(_) >> { - List traceList = it[0] - acceptedSpanCount.getAndAdd(traceList.size()) - acceptedCount.getAndIncrement() - } - AtomicInteger sampledSpansCount = new AtomicInteger() - SingleSpanSampler singleSpanSampler = new SingleSpanSampler() { - int counter = 0 - boolean setSamplingPriority(CoreSpan span) { - if (counter++ % 2 == 0) { - sampledSpansCount.incrementAndGet() - return true - } - // drop every other trace span - return false - } - } - TraceProcessingWorker worker = new TraceProcessingWorker(10, healthMetrics, countingDispatcher, { true }, FAST_LANE, 100, TimeUnit.SECONDS, singleSpanSampler) - worker.start() - - when: "traces are submitted" - for (int i = 0; i < traceCount; ++i) { - worker.publish(trace.get(0), priority, trace) - } - - then: "traces are passed through unless rejected on submission" - conditions.eventually { - assert acceptedTraces == acceptedCount.get() - assert acceptedSpans == acceptedSpanCount.get() - assert sampledSingleSpans == sampledSpansCount.get() - } - - cleanup: - worker.close() - - where: - priority | traceCount | acceptedTraces | acceptedSpans | sampledSingleSpans | trace - SAMPLER_DROP | 1 | 1 | 1 | 1 | [Mock(DDSpan)] - USER_DROP | 1 | 1 | 1 | 1 | [Mock(DDSpan), Mock(DDSpan)] - SAMPLER_DROP | 1 | 1 | 2 | 2 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - USER_DROP | 1 | 1 | 2 | 2 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - SAMPLER_DROP | 1 | 1 | 3 | 3 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - USER_DROP | 2 | 1 | 1 | 1 | [Mock(DDSpan)] // expectedTraceCount = 1 b/o 2nd trace's only span gets unsampled - SAMPLER_DROP | 2 | 2 | 2 | 2 | [Mock(DDSpan), Mock(DDSpan)] - USER_DROP | 2 | 2 | 3 | 3 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - SAMPLER_DROP | 2 | 2 | 4 | 4 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - USER_DROP | 2 | 2 | 5 | 5 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - SAMPLER_DROP | 10 | 5 | 5 | 5 | [Mock(DDSpan)] // expectedTraceCount = 5 b/o every 2nd trace's only span gets unsampled - USER_DROP | 10 | 10 | 10 | 10 | [Mock(DDSpan), Mock(DDSpan)] - SAMPLER_DROP | 10 | 10 | 15 | 15 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - USER_DROP | 10 | 10 | 20 | 20 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - SAMPLER_DROP | 10 | 10 | 25 | 25 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - // do not dispatch kept traces to the single span sampler - SAMPLER_KEEP | 1 | 1 | 1 | 0 | [Mock(DDSpan)] - USER_KEEP | 1 | 1 | 2 | 0 | [Mock(DDSpan), Mock(DDSpan)] - SAMPLER_KEEP | 1 | 1 | 3 | 0 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - USER_KEEP | 1 | 1 | 4 | 0 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - SAMPLER_KEEP | 1 | 1 | 5 | 0 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - USER_KEEP | 2 | 2 | 2 | 0 | [Mock(DDSpan)] - SAMPLER_KEEP | 2 | 2 | 4 | 0 | [Mock(DDSpan), Mock(DDSpan)] - USER_KEEP | 2 | 2 | 6 | 0 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - SAMPLER_KEEP | 2 | 2 | 8 | 0 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - USER_KEEP | 2 | 2 | 10 | 0 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - SAMPLER_KEEP | 10 | 10 | 10 | 0 | [Mock(DDSpan)] - USER_KEEP | 10 | 10 | 20 | 0 | [Mock(DDSpan), Mock(DDSpan)] - SAMPLER_KEEP | 10 | 10 | 30 | 0 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - USER_KEEP | 10 | 10 | 40 | 0 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - SAMPLER_KEEP | 10 | 10 | 50 | 0 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - } - - def "send unsampled traces to the SpanProcessingWorker and expect all spans dispatched when dropping policy is inactive"() { - setup: - HealthMetrics healthMetrics = Mock(HealthMetrics) - AtomicInteger chunksCount = new AtomicInteger() - AtomicInteger spansCount = new AtomicInteger() - PayloadDispatcherImpl countingDispatcher = Mock(PayloadDispatcherImpl) - countingDispatcher.addTrace(_) >> { - List traceList = it[0] - spansCount.getAndAdd(traceList.size()) - chunksCount.getAndIncrement() - } - AtomicInteger sampledSpansCount = new AtomicInteger() - SingleSpanSampler singleSpanSampler = new SingleSpanSampler() { - int counter = 0 - boolean setSamplingPriority(CoreSpan span) { - if (counter++ % 2 == 0) { - sampledSpansCount.incrementAndGet() - return true - } - // drop every other trace span - return false - } - } - TraceProcessingWorker worker = new TraceProcessingWorker(10, healthMetrics, countingDispatcher, { false }, FAST_LANE, 100, TimeUnit.SECONDS, singleSpanSampler) - worker.start() - - when: "traces are submitted" - for (int i = 0; i < traceCount; ++i) { - worker.publish(trace.get(0), priority, trace) - } - - then: "traces are passed through unless rejected on submission" - conditions.eventually { - assert expectedChunks == chunksCount.get() - assert expectedSpans == spansCount.get() - assert sampledSingleSpans == sampledSpansCount.get() - } - - cleanup: - worker.close() - - where: - priority | traceCount | expectedChunks | expectedSpans | sampledSingleSpans | trace - SAMPLER_DROP | 1 | 1 | 1 | 1 | [Mock(DDSpan)] - USER_DROP | 1 | 2 | 2 | 1 | [Mock(DDSpan), Mock(DDSpan)] - SAMPLER_DROP | 1 | 2 | 3 | 2 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - USER_DROP | 1 | 2 | 4 | 2 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - SAMPLER_DROP | 1 | 2 | 5 | 3 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - USER_DROP | 2 | 2 | 2*1 | 1 | [Mock(DDSpan)] - SAMPLER_DROP | 2 | 2*2 | 2*2 | 2 | [Mock(DDSpan), Mock(DDSpan)] - USER_DROP | 2 | 2*2 | 2*3 | 3 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - SAMPLER_DROP | 2 | 2*2 | 2*4 | 4 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - USER_DROP | 2 | 2*2 | 2*5 | 5 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - USER_DROP | 10 | 10 | 10 | 10/2*1 | [Mock(DDSpan)] - SAMPLER_DROP | 10 | 10*2 | 20 | 10/2*2 | [Mock(DDSpan), Mock(DDSpan)] - USER_DROP | 10 | 10*2 | 30 | 10/2*3 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - SAMPLER_DROP | 10 | 10*2 | 40 | 10/2*4 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - USER_DROP | 10 | 10*2 | 50 | 10/2*5 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - // do not dispatch kept traces to the single span sampler - SAMPLER_KEEP | 1 | 1 | 1 | 0 | [Mock(DDSpan)] - USER_KEEP | 1 | 1 | 2 | 0 | [Mock(DDSpan), Mock(DDSpan)] - SAMPLER_KEEP | 1 | 1 | 3 | 0 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - USER_KEEP | 1 | 1 | 4 | 0 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - SAMPLER_KEEP | 1 | 1 | 5 | 0 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - USER_KEEP | 2 | 2 | 2*1 | 0 | [Mock(DDSpan)] - SAMPLER_KEEP | 2 | 2 | 2*2 | 0 | [Mock(DDSpan), Mock(DDSpan)] - USER_KEEP | 2 | 2 | 2*3 | 0 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - SAMPLER_KEEP | 2 | 2 | 2*4 | 0 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - USER_KEEP | 2 | 2 | 2*5 | 0 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - SAMPLER_KEEP | 10 | 10 | 10 | 0 | [Mock(DDSpan)] - USER_KEEP | 10 | 10 | 20 | 0 | [Mock(DDSpan), Mock(DDSpan)] - SAMPLER_KEEP | 10 | 10 | 30 | 0 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - USER_KEEP | 10 | 10 | 40 | 0 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - SAMPLER_KEEP | 10 | 10 | 50 | 0 | [Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan), Mock(DDSpan)] - } -} diff --git a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/WriterFactoryTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/common/writer/WriterFactoryTest.groovy deleted file mode 100644 index d20bd475cac..00000000000 --- a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/WriterFactoryTest.groovy +++ /dev/null @@ -1,242 +0,0 @@ -package datadog.trace.common.writer - -import static datadog.trace.api.config.TracerConfig.PRIORITIZATION_TYPE - -import datadog.communication.ddagent.DDAgentFeaturesDiscovery -import datadog.communication.ddagent.SharedCommunicationObjects -import datadog.trace.api.Config -import datadog.trace.api.config.OtlpConfig -import datadog.trace.api.intake.TrackType -import datadog.trace.common.sampling.Sampler -import datadog.trace.common.writer.ddagent.DDAgentApi -import datadog.trace.common.writer.ddagent.Prioritization -import datadog.trace.common.writer.ddintake.DDEvpProxyApi -import datadog.trace.common.writer.ddintake.DDIntakeApi -import datadog.trace.core.monitor.HealthMetrics -import datadog.trace.core.otlp.common.OtlpGrpcSender -import datadog.trace.core.otlp.common.OtlpHttpSender -import datadog.trace.test.util.DDSpecification -import groovy.json.JsonBuilder -import java.util.stream.Collectors -import okhttp3.Call -import okhttp3.HttpUrl -import okhttp3.MediaType -import okhttp3.OkHttpClient -import okhttp3.Protocol -import okhttp3.Request -import okhttp3.Response -import okhttp3.ResponseBody - -class WriterFactoryTest extends DDSpecification { - - def "test writer creation for #configuredType when agentHasEvpProxy=#hasEvpProxy evpProxySupportsCompression=#evpProxySupportsCompression ciVisibilityAgentless=#isCiVisibilityAgentlessEnabled"() { - setup: - def config = Mock(Config) - config.apiKey >> "my-api-key" - config.agentUrl >> "http://my-agent.url" - config.getEnumValue(PRIORITIZATION_TYPE, _, _) >> Prioritization.FAST_LANE - config.tracerMetricsEnabled >> true - config.isCiVisibilityEnabled() >> true - config.isCiVisibilityCodeCoverageEnabled() >> false - - // Mock agent info response - def response = buildHttpResponse(hasEvpProxy, evpProxySupportsCompression, HttpUrl.parse(config.agentUrl + "/info")) - - // Mock HTTP client that simulates delayed response for async feature discovery - def mockCall = Mock(Call) - def mockHttpClient = Mock(OkHttpClient) - mockCall.execute() >> { - // Add a delay - sleep(400) - return response - } - mockHttpClient.newCall(_ as Request) >> mockCall - - // Create SharedCommunicationObjects with mocked HTTP client - def sharedComm = new SharedCommunicationObjects() - sharedComm.agentHttpClient = mockHttpClient - sharedComm.agentUrl = HttpUrl.parse(config.agentUrl) - sharedComm.createRemaining(config) - - def sampler = Mock(Sampler) - - when: - config.ciVisibilityAgentlessEnabled >> isCiVisibilityAgentlessEnabled - - def writer = WriterFactory.createWriter(config, sharedComm, sampler, null, HealthMetrics.NO_OP, configuredType) - - def apis - def apiClasses - if (expectedApiClasses != null) { - apis = ((RemoteWriter) writer).apis - apiClasses = apis.stream().map(Object::getClass).collect(Collectors.toList()) - } else { - apis = Collections.emptyList() - apiClasses = Collections.emptyList() - } - - then: - writer.class == expectedWriterClass - expectedApiClasses == null || apiClasses == expectedApiClasses - expectedApiClasses == null || apis.stream().allMatch(api -> api.isCompressionEnabled() == isCompressionEnabled) - - where: - configuredType | hasEvpProxy | evpProxySupportsCompression | isCiVisibilityAgentlessEnabled | expectedWriterClass | expectedApiClasses | isCompressionEnabled - "LoggingWriter" | true | false | true | LoggingWriter | null | false - "PrintingWriter" | true | false | true | PrintingWriter | null | false - "TraceStructureWriter" | true | false | true | TraceStructureWriter | null | false - "MultiWriter:LoggingWriter,PrintingWriter" | true | false | true | MultiWriter | null | false - "DDIntakeWriter" | true | false | true | DDIntakeWriter | [DDIntakeApi] | true - "DDIntakeWriter" | true | false | false | DDIntakeWriter | [DDEvpProxyApi] | false - "DDIntakeWriter" | false | false | true | DDIntakeWriter | [DDIntakeApi] | true - "DDIntakeWriter" | false | false | false | DDIntakeWriter | [DDIntakeApi] | true - "DDAgentWriter" | true | false | true | DDIntakeWriter | [DDIntakeApi] | true - "DDAgentWriter" | true | false | false | DDIntakeWriter | [DDEvpProxyApi] | false - "DDAgentWriter" | true | true | false | DDIntakeWriter | [DDEvpProxyApi] | true - "DDAgentWriter" | false | false | true | DDIntakeWriter | [DDIntakeApi] | true - "DDAgentWriter" | false | false | false | DDAgentWriter | [DDAgentApi] | false - "not-found" | true | false | true | DDIntakeWriter | [DDIntakeApi] | true - "not-found" | true | false | false | DDIntakeWriter | [DDEvpProxyApi] | false - "not-found" | false | false | true | DDIntakeWriter | [DDIntakeApi] | true - "not-found" | false | false | false | DDAgentWriter | [DDAgentApi] | false - } - - def "test writer creation for #configuredType when agentHasEvpProxy=#hasEvpProxy llmObsAgentless=#isLlmObsAgentlessEnabled for LLM Observability"() { - setup: - def config = Mock(Config) - config.apiKey >> "my-api-key" - config.agentUrl >> "http://my-agent.url" - config.getEnumValue(PRIORITIZATION_TYPE, _, _) >> Prioritization.FAST_LANE - config.tracerMetricsEnabled >> true - config.isLlmObsEnabled() >> true - - // Mock agent info response - def response - if (agentRunning) { - response = buildHttpResponse(hasEvpProxy, true, HttpUrl.parse(config.agentUrl + "/info")) - } else { - response = buildHttpResponseNotOk(HttpUrl.parse(config.agentUrl + "/info")) - } - - // Mock HTTP client that simulates delayed response for async feature discovery - def mockCall = Mock(Call) - def mockHttpClient = Mock(OkHttpClient) - mockCall.execute() >> { - // Add a delay - sleep(400) - return response - } - mockHttpClient.newCall(_ as Request) >> mockCall - - // Create SharedCommunicationObjects with mocked HTTP client - def sharedComm = new SharedCommunicationObjects() - sharedComm.agentHttpClient = mockHttpClient - sharedComm.agentUrl = HttpUrl.parse(config.agentUrl) - sharedComm.createRemaining(config) - - def sampler = Mock(Sampler) - - when: - config.llmObsAgentlessEnabled >> isLlmObsAgentlessEnabled - - def writer = WriterFactory.createWriter(config, sharedComm, sampler, null, HealthMetrics.NO_OP, configuredType) - def llmObsApiClasses = ((RemoteWriter) writer).apis - .stream() - .filter(api -> { - try { - def trackTypeField = api.class.getDeclaredField("trackType") - trackTypeField.setAccessible(true) - return trackTypeField.get(api) == TrackType.LLMOBS - } catch (Exception e) { - return false - } - }) - .map(Object::getClass) - .collect(Collectors.toList()) - - then: - writer.class == expectedWriterClass - llmObsApiClasses == expectedLlmObsApiClasses - - where: - configuredType | agentRunning | hasEvpProxy | isLlmObsAgentlessEnabled |expectedWriterClass | expectedLlmObsApiClasses - "DDIntakeWriter" | true | true | false | DDIntakeWriter | [DDEvpProxyApi] - "DDIntakeWriter" | true | false | false | DDIntakeWriter | [DDIntakeApi] - "DDIntakeWriter" | false | false | false | DDIntakeWriter | [DDIntakeApi] - "DDIntakeWriter" | true | true | true | DDIntakeWriter | [DDIntakeApi] - "DDIntakeWriter" | true | false | true | DDIntakeWriter | [DDIntakeApi] - "DDIntakeWriter" | false | false | true | DDIntakeWriter | [DDIntakeApi] - } - - def "test writer creation for OtlpWriter wires #protocol+#compression"() { - setup: - def config = Mock(Config) - def headers = ["api-key": "secret"] - def readField = { Object instance, String fieldName -> - def field = instance.class.getDeclaredField(fieldName) - field.setAccessible(true) - return field.get(instance) - } - - config.getTraceFlushIntervalSeconds() >> 1.0f - config.getOtlpTracesEndpoint() >> endpoint - config.getOtlpTracesHeaders() >> headers - config.getOtlpTracesProtocol() >> protocol - config.getOtlpTracesCompression() >> compression - config.getOtlpTracesTimeout() >> 5000 - - when: - // OTLP branch in WriterFactory does not consult sharedComm or sampler, so nulls are safe here. - def writer = WriterFactory.createWriter(config, null, null, null, HealthMetrics.NO_OP, "OtlpWriter") - def sender = readField(writer, "sender") - - then: - writer.class == OtlpWriter - sender.class == expectedSenderClass - readField(sender, "url").toString() == expectedUrl - readField(sender, "headers") == headers - readField(sender, "gzip") == expectedGzip - - cleanup: - writer?.close() - - where: - protocol | compression | endpoint | expectedSenderClass | expectedUrl | expectedGzip - OtlpConfig.Protocol.HTTP_PROTOBUF | OtlpConfig.Compression.NONE | "http://otel-collector:4318/v1/traces" | OtlpHttpSender | "http://otel-collector:4318/v1/traces" | false - OtlpConfig.Protocol.HTTP_PROTOBUF | OtlpConfig.Compression.GZIP | "http://otel-collector:4318/v1/traces" | OtlpHttpSender | "http://otel-collector:4318/v1/traces" | true - OtlpConfig.Protocol.GRPC | OtlpConfig.Compression.NONE | "http://otel-collector:4317" | OtlpGrpcSender | "http://otel-collector:4317/opentelemetry.proto.collector.trace.v1.TraceService/Export" | false - } - - Response buildHttpResponse(boolean hasEvpProxy, boolean evpProxySupportsCompression, HttpUrl agentUrl) { - def endpoints = [] - if (hasEvpProxy && evpProxySupportsCompression) { - endpoints = [DDAgentFeaturesDiscovery.V4_EVP_PROXY_ENDPOINT] - } else if (hasEvpProxy) { - endpoints = [DDAgentFeaturesDiscovery.V2_EVP_PROXY_ENDPOINT] - } else { - endpoints = [DDAgentFeaturesDiscovery.V04_ENDPOINT] - } - - def response = [ - "version" : "7.40.0", - "endpoints" : endpoints, - ] - - def builder = new Response.Builder() - .code(200) - .message("OK") - .protocol(Protocol.HTTP_1_1) - .request(new Request.Builder().url(agentUrl.resolve("/info")).build()) - .body(ResponseBody.create(MediaType.parse("application/json"), new JsonBuilder(response).toString())) - return builder.build() - } - - Response buildHttpResponseNotOk(HttpUrl agentUrl) { - def builder = new Response.Builder() - .code(500) - .message("ERROR") - .protocol(Protocol.HTTP_1_1) - .request(new Request.Builder().url(agentUrl.resolve("/info")).build()) - return builder.build() - } -} diff --git a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/ddintake/DDEvpProxyApiTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/common/writer/ddintake/DDEvpProxyApiTest.groovy deleted file mode 100644 index 52ad90a9514..00000000000 --- a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/ddintake/DDEvpProxyApiTest.groovy +++ /dev/null @@ -1,339 +0,0 @@ -package datadog.trace.common.writer.ddintake - -import com.fasterxml.jackson.core.type.TypeReference -import com.fasterxml.jackson.databind.ObjectMapper -import datadog.communication.EvpProxy -import datadog.communication.serialization.ByteBufferConsumer -import datadog.communication.serialization.FlushingBuffer -import datadog.communication.serialization.msgpack.MsgPackWriter -import datadog.trace.api.DDTags -import datadog.trace.api.civisibility.CiVisibilityWellKnownTags -import datadog.trace.api.intake.TrackType -import datadog.trace.bootstrap.instrumentation.api.InternalSpanTypes -import datadog.trace.bootstrap.instrumentation.api.ServiceNameSources -import datadog.trace.bootstrap.instrumentation.api.Tags -import datadog.trace.common.writer.Payload -import datadog.trace.core.DDSpan -import datadog.trace.core.test.DDCoreSpecification -import okhttp3.HttpUrl -import org.apache.commons.io.IOUtils -import org.msgpack.jackson.dataformat.MessagePackFactory -import spock.lang.Timeout - -import java.nio.ByteBuffer -import java.util.zip.GZIPInputStream - -import static datadog.communication.ddagent.DDAgentFeaturesDiscovery.V2_EVP_PROXY_ENDPOINT -import static datadog.communication.ddagent.DDAgentFeaturesDiscovery.V4_EVP_PROXY_ENDPOINT -import static datadog.trace.agent.test.server.http.TestHttpServer.httpServer - -@Timeout(20) -class DDEvpProxyApiTest extends DDCoreSpecification { - - static CiVisibilityWellKnownTags wellKnownTags = new CiVisibilityWellKnownTags( - "my-runtime-id", "my-env", "my-language", - "my-runtime-name", "my-runtime-version", "my-runtime-vendor", - "my-os-arch", "my-os-platform", "my-os-version", "false") - - static String intakeSubdomain = "citestcycle-intake" - static msgPackMapper = new ObjectMapper(new MessagePackFactory()) - - static newAgentEvpProxy(String path) { - httpServer { - handlers { - post(path) { - if (request.contentType != "application/msgpack") { - response.status(400).send("wrong type: $request.contentType") - } else { - response.status(200).send() - } - } - } - } - } - - def "sending an empty list of traces returns no errors"() { - setup: - def path = buildAgentEvpProxyPath(evpProxyEndpoint, trackType, apiVersion) - def agentEvpProxy = newAgentEvpProxy(path) - def client = createEvpProxyApi(agentEvpProxy.address.toString(), evpProxyEndpoint, trackType, false) - def payload = prepareTraces(trackType, false, []) - - expect: - def clientResponse = client.sendSerializedTraces(payload) - clientResponse.success() - clientResponse.status().present - clientResponse.status().asInt == 200 - agentEvpProxy.getLastRequest().path == path - agentEvpProxy.getLastRequest().getHeader(EvpProxy.SUBDOMAIN_HEADER) == intakeSubdomain - - cleanup: - agentEvpProxy.close() - - where: - trackType | apiVersion | evpProxyEndpoint - TrackType.CITESTCYCLE | "v2" | V2_EVP_PROXY_ENDPOINT - } - - def "retries when backend returns 5xx"() { - setup: - def retry = 1 - def path = buildAgentEvpProxyPath(evpProxyEndpoint, trackType, apiVersion) - def agentEvpProxy = httpServer { - handlers { - post(path) { - if (retry < 5) { - response.status(503).send() - retry += 1 - } else { - response.status(200).send() - } - } - } - } - - def client = createEvpProxyApi(agentEvpProxy.address.toString(), evpProxyEndpoint, trackType, false) - def payload = prepareTraces(trackType, false, []) - - expect: - def clientResponse = client.sendSerializedTraces(payload) - clientResponse.success() - clientResponse.status().present - clientResponse.status().asInt == 200 - agentEvpProxy.getLastRequest().path == path - agentEvpProxy.getLastRequest().getHeader(EvpProxy.SUBDOMAIN_HEADER) == intakeSubdomain - - cleanup: - agentEvpProxy.close() - - where: - trackType | apiVersion | evpProxyEndpoint - TrackType.CITESTCYCLE | "v2" | V2_EVP_PROXY_ENDPOINT - } - - def "content is sent as MSGPACK"() { - setup: - def path = buildAgentEvpProxyPath(evpProxyEndpoint, trackType, apiVersion) - def agentEvpProxy = httpServer { - handlers { - post(path) { - response.send() - } - } - } - - def client = createEvpProxyApi(agentEvpProxy.address.toString(), evpProxyEndpoint, trackType, compressionEnabled) - def payload = prepareTraces(trackType, compressionEnabled, traces) - - expect: - client.sendSerializedTraces(payload).status() - agentEvpProxy.getLastRequest().contentType == "application/msgpack" - convertMap(agentEvpProxy.getLastRequest().body, compressionEnabled) == expectedRequestBody - - cleanup: - agentEvpProxy.close() - - where: - // spotless:off - trackType | apiVersion | evpProxyEndpoint | compressionEnabled | traces | expectedRequestBody - TrackType.CITESTCYCLE | "v2" | V2_EVP_PROXY_ENDPOINT | false | [] | [:] - - TrackType.CITESTCYCLE | "v2" | V2_EVP_PROXY_ENDPOINT | false | [[buildSpan(1L, "fakeType", ["service.name": "my-service"])]] | new TreeMap<>([ - "version" : 1, - "metadata": new TreeMap<>([ - "*": new TreeMap<>([ - "env" : "my-env", - "runtime-id" : "my-runtime-id", - "language" : "my-language", - (Tags.RUNTIME_NAME) : "my-runtime-name", - (Tags.RUNTIME_VERSION) : "my-runtime-version", - (Tags.RUNTIME_VENDOR) : "my-runtime-vendor", - (Tags.OS_ARCHITECTURE) : "my-os-arch", - (Tags.OS_PLATFORM) : "my-os-platform", - (Tags.OS_VERSION) : "my-os-version", - (DDTags.TEST_IS_USER_PROVIDED_SERVICE): "false" - ])]), - "events" : [new TreeMap<>([ - "type" : "span", - "version": 1, - "content": new TreeMap<>([ - "service" : "my-service", - "name" : "fakeOperation", - "resource" : "fakeResource", - "error" : 0, - "trace_id" : 1L, - "span_id" : 1L, - "parent_id": 0L, - "start" : 1000L, - "duration" : 10L, - "meta" : [(DDTags.DD_SVC_SRC): ServiceNameSources.MANUAL.toString()], - "metrics" : [:] - ]) - ])] - ]) - TrackType.CITESTCYCLE | "v2" | V2_EVP_PROXY_ENDPOINT | false | [[buildSpan(1L, InternalSpanTypes.TEST, ["test_suite_id": 123L, "test_module_id": 456L])]] | new TreeMap<>([ - "version" : 1, - "metadata": new TreeMap<>([ - "*": new TreeMap<>([ - "env" : "my-env", - "runtime-id" : "my-runtime-id", - "language" : "my-language", - (Tags.RUNTIME_NAME) : "my-runtime-name", - (Tags.RUNTIME_VERSION) : "my-runtime-version", - (Tags.RUNTIME_VENDOR) : "my-runtime-vendor", - (Tags.OS_ARCHITECTURE) : "my-os-arch", - (Tags.OS_PLATFORM) : "my-os-platform", - (Tags.OS_VERSION) : "my-os-version", - (DDTags.TEST_IS_USER_PROVIDED_SERVICE): "false" - ])]), - "events" : [new TreeMap<>([ - "type" : "test", - "version": 2, - "content": new TreeMap<>([ - "test_suite_id" : 123L, - "test_module_id": 456L, - "service" : "fakeService", - "name" : "fakeOperation", - "resource" : "fakeResource", - "error" : 0, - "trace_id" : 1L, - "span_id" : 1L, - "parent_id" : 0L, - "start" : 1000L, - "duration" : 10L, - "meta" : [:], - "metrics" : [:] - ]) - ])] - ]) - TrackType.CITESTCYCLE | "v2" | V2_EVP_PROXY_ENDPOINT | false | [[buildSpan(1L, InternalSpanTypes.TEST_SUITE_END, ["test_suite_id": 123L, "test_module_id": 456L])]] | new TreeMap<>([ - "version" : 1, - "metadata": new TreeMap<>([ - "*": new TreeMap<>([ - "env" : "my-env", - "runtime-id" : "my-runtime-id", - "language" : "my-language", - (Tags.RUNTIME_NAME) : "my-runtime-name", - (Tags.RUNTIME_VERSION) : "my-runtime-version", - (Tags.RUNTIME_VENDOR) : "my-runtime-vendor", - (Tags.OS_ARCHITECTURE) : "my-os-arch", - (Tags.OS_PLATFORM) : "my-os-platform", - (Tags.OS_VERSION) : "my-os-version", - (DDTags.TEST_IS_USER_PROVIDED_SERVICE): "false" - ])]), - "events" : [new TreeMap<>([ - "type" : "test_suite_end", - "version": 1, - "content": new TreeMap<>([ - "test_suite_id" : 123L, - "test_module_id": 456L, - "service" : "fakeService", - "name" : "fakeOperation", - "resource" : "fakeResource", - "error" : 0, - "start" : 1000L, - "duration" : 10L, - "meta" : [:], - "metrics" : [:] - ]) - ])] - ]) - TrackType.CITESTCYCLE | "v2" | V4_EVP_PROXY_ENDPOINT | true | [[buildSpan(1L, InternalSpanTypes.TEST_MODULE_END, ["test_module_id": 456L])]] | new TreeMap<>([ - "version" : 1, - "metadata": new TreeMap<>([ - "*": new TreeMap<>([ - "env" : "my-env", - "runtime-id" : "my-runtime-id", - "language" : "my-language", - (Tags.RUNTIME_NAME) : "my-runtime-name", - (Tags.RUNTIME_VERSION) : "my-runtime-version", - (Tags.RUNTIME_VENDOR) : "my-runtime-vendor", - (Tags.OS_ARCHITECTURE) : "my-os-arch", - (Tags.OS_PLATFORM) : "my-os-platform", - (Tags.OS_VERSION) : "my-os-version", - (DDTags.TEST_IS_USER_PROVIDED_SERVICE): "false" - ])]), - "events" : [new TreeMap<>([ - "type" : "test_module_end", - "version": 1, - "content": new TreeMap<>([ - "test_module_id": 456L, - "service" : "fakeService", - "name" : "fakeOperation", - "resource" : "fakeResource", - "error" : 0, - "start" : 1000L, - "duration" : 10L, - "meta" : [:], - "metrics" : [:] - ]) - ])] - ]) - - // spotless:on - ignore = traces.each { - it.each { - it.finish() - it.@durationNano = 10 - } - } - } - - static Map convertMap(byte[] bytes, boolean compressionEnabled) { - if (compressionEnabled) { - bytes = decompress(bytes) - } - return msgPackMapper.readValue(bytes, new TypeReference>() {}) - } - - static byte[] decompress(byte[] bytes) { - def baos = new ByteArrayOutputStream() - try (GZIPInputStream zip = new GZIPInputStream(new ByteArrayInputStream(bytes))) { - IOUtils.copy(zip, baos) - } - return baos.toByteArray() - } - - static class Traces implements ByteBufferConsumer { - int traceCount - ByteBuffer buffer - - @Override - void accept(int messageCount, ByteBuffer buffer) { - this.buffer = buffer - this.traceCount = messageCount - } - } - - def createEvpProxyApi(String agentUrl, String evpProxyEndpoint, TrackType trackType, boolean compressionEnabled) { - return DDEvpProxyApi.builder() - .agentUrl(HttpUrl.get(agentUrl)) - .evpProxyEndpoint(evpProxyEndpoint) - .trackType(trackType) - .compressionEnabled(compressionEnabled) - .build() - } - - def discoverMapper(TrackType trackType, boolean compressionEnabled) { - def mapperDiscover = new DDIntakeMapperDiscovery(trackType, wellKnownTags, compressionEnabled) - mapperDiscover.discover() - return mapperDiscover.getMapper() - } - - def buildAgentEvpProxyPath(String evpProxyEndpoint, TrackType trackType, String apiVersion) { - return "/" + evpProxyEndpoint + "api/" + apiVersion + "/" + trackType.name().toLowerCase() - } - - Payload prepareTraces(TrackType trackType, boolean compressionEnabled, List> traces) { - Traces traceCapture = new Traces() - def packer = new MsgPackWriter(new FlushingBuffer(1 << 20, traceCapture)) - def mapper = discoverMapper(trackType, compressionEnabled) - for (trace in traces) { - packer.format(trace, mapper) - } - packer.flush() - return mapper.newPayload() - .withBody(traceCapture.traceCount, - traces.isEmpty() ? ByteBuffer.allocate(0) : traceCapture.buffer) - } -} diff --git a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/ddintake/DDIntakeApiTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/common/writer/ddintake/DDIntakeApiTest.groovy deleted file mode 100644 index ea3932854b4..00000000000 --- a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/ddintake/DDIntakeApiTest.groovy +++ /dev/null @@ -1,360 +0,0 @@ -package datadog.trace.common.writer.ddintake - -import com.fasterxml.jackson.core.type.TypeReference -import com.fasterxml.jackson.databind.ObjectMapper -import datadog.communication.serialization.ByteBufferConsumer -import datadog.communication.serialization.FlushingBuffer -import datadog.communication.serialization.msgpack.MsgPackWriter -import datadog.trace.api.DDTags -import datadog.trace.api.civisibility.CiVisibilityWellKnownTags -import datadog.trace.api.intake.TrackType -import datadog.trace.bootstrap.instrumentation.api.InternalSpanTypes -import datadog.trace.bootstrap.instrumentation.api.ServiceNameSources -import datadog.trace.bootstrap.instrumentation.api.Tags -import datadog.trace.common.writer.Payload -import datadog.trace.core.DDSpan -import datadog.trace.core.test.DDCoreSpecification -import okhttp3.HttpUrl -import org.apache.commons.io.IOUtils -import org.msgpack.jackson.dataformat.MessagePackFactory -import spock.lang.Timeout - -import java.nio.ByteBuffer -import java.util.zip.GZIPInputStream - -import static datadog.trace.agent.test.server.http.TestHttpServer.httpServer - -@Timeout(20) -class DDIntakeApiTest extends DDCoreSpecification { - - static CiVisibilityWellKnownTags wellKnownTags = new CiVisibilityWellKnownTags( - "my-runtime-id", "my-env", "my-language", - "my-runtime-name", "my-runtime-version", "my-runtime-vendor", - "my-os-arch", "my-os-platform", "my-os-version", "false") - - static String apiKey = "my-secret-apikey" - static msgPackMapper = new ObjectMapper(new MessagePackFactory()) - - static newIntake(String path) { - httpServer { - handlers { - post(path) { - if (request.contentType != "application/msgpack") { - response.status(400).send("wrong type: $request.contentType") - } else { - response.status(200).send() - } - } - } - } - } - - def "sending an empty list of traces returns no errors"() { - setup: - def path = buildIntakePath(trackType, apiVersion) - def intake = newIntake(path) - def client = createIntakeApi(intake.address.toString(), trackType) - def payload = prepareTraces(trackType, []) - - expect: - def clientResponse = client.sendSerializedTraces(payload) - clientResponse.success() - clientResponse.status().present - clientResponse.status().asInt == 200 - intake.getLastRequest().path == path - - cleanup: - intake.close() - - where: - trackType | apiVersion - TrackType.CITESTCYCLE | "v2" - } - - def "retries when backend returns 5xx"() { - setup: - def retry = 1 - def path = buildIntakePath(trackType, apiVersion) - def intake = httpServer { - handlers { - post(path) { - if (retry < 5) { - response.status(503).send() - retry += 1 - } else { - response.status(200).send() - } - } - } - } - - def client = createIntakeApi(intake.address.toString(), trackType) - def payload = prepareTraces(trackType, []) - - expect: - def clientResponse = client.sendSerializedTraces(payload) - clientResponse.success() - clientResponse.status().present - clientResponse.status().asInt == 200 - intake.getLastRequest().path == path - - cleanup: - intake.close() - - where: - trackType | apiVersion - TrackType.CITESTCYCLE | "v2" - } - - def "retries when backend returns 429 Too Many Requests"() { - setup: - def retry = 0 - def path = buildIntakePath(trackType, apiVersion) - def intake = httpServer { - handlers { - post(path) { - if (retry < 1) { - response.status(429).addHeader("x-ratelimit-reset", "0").send() - retry += 1 - } else { - response.status(200).send() - } - } - } - } - - def client = createIntakeApi(intake.address.toString(), trackType) - def payload = prepareTraces(trackType, []) - - expect: - def clientResponse = client.sendSerializedTraces(payload) - clientResponse.success() - clientResponse.status().present - clientResponse.status().asInt == 200 - intake.getLastRequest().path == path - - cleanup: - intake.close() - - where: - trackType | apiVersion - TrackType.CITESTCYCLE | "v2" - } - - def "content is sent as MSGPACK"() { - setup: - def path = buildIntakePath(trackType, apiVersion) - def intake = httpServer { - handlers { - post(path) { - response.send() - } - } - } - - def client = createIntakeApi(intake.address.toString(), trackType) - def payload = prepareTraces(trackType, traces) - - expect: - client.sendSerializedTraces(payload).status().present - intake.lastRequest.contentType == "application/msgpack" - convertMap(intake.lastRequest.body) == expectedRequestBody - - cleanup: - intake.close() - - where: - // spotless:off - trackType | apiVersion | traces | expectedRequestBody - TrackType.CITESTCYCLE | "v2" | [] | [:] - TrackType.CITESTCYCLE | "v2" | [[buildSpan(1L, "fakeType", ["service.name": "my-service"])]] | new TreeMap<>([ - "version" : 1, - "metadata": new TreeMap<>([ - "*": new TreeMap<>([ - "env" : "my-env", - "runtime-id" : "my-runtime-id", - "language" : "my-language", - (Tags.RUNTIME_NAME) : "my-runtime-name", - (Tags.RUNTIME_VERSION) : "my-runtime-version", - (Tags.RUNTIME_VENDOR) : "my-runtime-vendor", - (Tags.OS_ARCHITECTURE) : "my-os-arch", - (Tags.OS_PLATFORM) : "my-os-platform", - (Tags.OS_VERSION) : "my-os-version", - (DDTags.TEST_IS_USER_PROVIDED_SERVICE): "false", - ])]), - "events" : [new TreeMap<>([ - "type" : "span", - "version": 1, - "content": new TreeMap<>([ - "service" : "my-service", - "name" : "fakeOperation", - "resource" : "fakeResource", - "error" : 0, - "trace_id" : 1L, - "span_id" : 1L, - "parent_id": 0L, - "start" : 1000L, - "duration" : 10L, - "meta" : [(DDTags.DD_SVC_SRC):ServiceNameSources.MANUAL.toString()], - "metrics" : [:] - ]) - ])] - ]) - TrackType.CITESTCYCLE | "v2" | [[buildSpan(1L, InternalSpanTypes.TEST, ["test_suite_id": 123L, "test_module_id": 456L])]] | new TreeMap<>([ - "version" : 1, - "metadata": new TreeMap<>([ - "*": new TreeMap<>([ - "env" : "my-env", - "runtime-id" : "my-runtime-id", - "language" : "my-language", - (Tags.RUNTIME_NAME) : "my-runtime-name", - (Tags.RUNTIME_VERSION) : "my-runtime-version", - (Tags.RUNTIME_VENDOR) : "my-runtime-vendor", - (Tags.OS_ARCHITECTURE) : "my-os-arch", - (Tags.OS_PLATFORM) : "my-os-platform", - (Tags.OS_VERSION) : "my-os-version", - (DDTags.TEST_IS_USER_PROVIDED_SERVICE): "false" - ])]), - "events" : [new TreeMap<>([ - "type" : "test", - "version": 2, - "content": new TreeMap<>([ - "test_suite_id" : 123L, - "test_module_id": 456L, - "service" : "fakeService", - "name" : "fakeOperation", - "resource" : "fakeResource", - "error" : 0, - "trace_id" : 1L, - "span_id" : 1L, - "parent_id" : 0L, - "start" : 1000L, - "duration" : 10L, - "meta" : [:], - "metrics" : [:] - ]) - ])] - ]) - TrackType.CITESTCYCLE | "v2" | [[buildSpan(1L, InternalSpanTypes.TEST_SUITE_END, ["test_suite_id": 123L, "test_module_id": 456L])]] | new TreeMap<>([ - "version" : 1, - "metadata": new TreeMap<>([ - "*": new TreeMap<>([ - "env" : "my-env", - "runtime-id" : "my-runtime-id", - "language" : "my-language", - (Tags.RUNTIME_NAME) : "my-runtime-name", - (Tags.RUNTIME_VERSION) : "my-runtime-version", - (Tags.RUNTIME_VENDOR) : "my-runtime-vendor", - (Tags.OS_ARCHITECTURE) : "my-os-arch", - (Tags.OS_PLATFORM) : "my-os-platform", - (Tags.OS_VERSION) : "my-os-version", - (DDTags.TEST_IS_USER_PROVIDED_SERVICE): "false" - ])]), - "events" : [new TreeMap<>([ - "type" : "test_suite_end", - "version": 1, - "content": new TreeMap<>([ - "test_suite_id" : 123L, - "test_module_id": 456L, - "service" : "fakeService", - "name" : "fakeOperation", - "resource" : "fakeResource", - "error" : 0, - "start" : 1000L, - "duration" : 10L, - "meta" : [:], - "metrics" : [:] - ]) - ])] - ]) - TrackType.CITESTCYCLE | "v2" | [[buildSpan(1L, InternalSpanTypes.TEST_MODULE_END, ["test_module_id": 456L])]] | new TreeMap<>([ - "version" : 1, - "metadata": new TreeMap<>([ - "*": new TreeMap<>([ - "env" : "my-env", - "runtime-id" : "my-runtime-id", - "language" : "my-language", - (Tags.RUNTIME_NAME) : "my-runtime-name", - (Tags.RUNTIME_VERSION) : "my-runtime-version", - (Tags.RUNTIME_VENDOR) : "my-runtime-vendor", - (Tags.OS_ARCHITECTURE) : "my-os-arch", - (Tags.OS_PLATFORM) : "my-os-platform", - (Tags.OS_VERSION) : "my-os-version", - (DDTags.TEST_IS_USER_PROVIDED_SERVICE): "false" - ])]), - "events" : [new TreeMap<>([ - "type" : "test_module_end", - "version": 1, - "content": new TreeMap<>([ - "test_module_id": 456L, - "service" : "fakeService", - "name" : "fakeOperation", - "resource" : "fakeResource", - "error" : 0, - "start" : 1000L, - "duration" : 10L, - "meta" : [:], - "metrics" : [:] - ]) - ])] - ]) - // spotless:on - ignore = traces.each { - it.each { - it.finish() - it.@durationNano = 10 - } - } - } - - static Map convertMap(byte[] bytes) { - return msgPackMapper.readValue(decompress(bytes), new TypeReference>() {}) - } - - static byte[] decompress(byte[] bytes) { - def baos = new ByteArrayOutputStream() - try (GZIPInputStream zip = new GZIPInputStream(new ByteArrayInputStream(bytes))) { - IOUtils.copy(zip, baos) - } - return baos.toByteArray() - } - - static class Traces implements ByteBufferConsumer { - int traceCount - ByteBuffer buffer - - @Override - void accept(int messageCount, ByteBuffer buffer) { - this.buffer = buffer - this.traceCount = messageCount - } - } - - def createIntakeApi(String url, TrackType trackType) { - HttpUrl hostUrl = HttpUrl.get(url) - return DDIntakeApi.builder().hostUrl(hostUrl).trackType(trackType).apiKey(apiKey).build() - } - - def discoverMapper(TrackType trackType) { - def mapperDiscover = new DDIntakeMapperDiscovery(trackType, wellKnownTags, true) - mapperDiscover.discover() - return mapperDiscover.getMapper() - } - - def buildIntakePath(TrackType trackType, String apiVersion) { - return String.format("/api/%s/%s", apiVersion, trackType.name().toLowerCase()) - } - - Payload prepareTraces(TrackType trackType, List> traces) { - Traces traceCapture = new Traces() - def packer = new MsgPackWriter(new FlushingBuffer(1 << 20, traceCapture)) - def mapper = discoverMapper(trackType) - for (trace in traces) { - packer.format(trace, mapper) - } - packer.flush() - return mapper.newPayload() - .withBody(traceCapture.traceCount, - traces.isEmpty() ? ByteBuffer.allocate(0) : traceCapture.buffer) - } -} diff --git a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/ddintake/DDIntakeTraceInterceptorTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/common/writer/ddintake/DDIntakeTraceInterceptorTest.groovy deleted file mode 100644 index 31c5c1c68ee..00000000000 --- a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/ddintake/DDIntakeTraceInterceptorTest.groovy +++ /dev/null @@ -1,89 +0,0 @@ -package datadog.trace.common.writer.ddintake - -import datadog.trace.bootstrap.instrumentation.api.Tags -import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString -import datadog.trace.common.writer.ListWriter -import datadog.trace.core.test.DDCoreSpecification -import spock.lang.Timeout - -@Timeout(100) -class DDIntakeTraceInterceptorTest extends DDCoreSpecification { - - def writer = new ListWriter() - def tracer = tracerBuilder().writer(writer).build() - - def setup() { - tracer.addTraceInterceptor(DDIntakeTraceInterceptor.INSTANCE) - } - - def cleanup() { - tracer?.close() - } - - def "test normalization for dd intake"() { - setup: - tracer.buildSpan("datadog", "my-operation-name") - .withResourceName("my-resource-name") - .withSpanType("my-span-type") - .withServiceName("my-service-name") - .withTag("some-tag-key", "some-tag-value") - .withTag("env", " My_____Env ") - .withTag(Tags.HTTP_STATUS, httpStatus) - .start().finish() - writer.waitForTraces(1) - - expect: - def trace = writer.firstTrace() - trace.size() == 1 - - def span = trace[0] - - span.getServiceName() == "my-service-name" - span.getOperationName() == "my_operation_name" - span.getResourceName() == "my-resource-name" - span.getSpanType() == "my-span-type" - span.getTag("some-tag-key") == "some-tag-value" - span.getTag("env") == "my_env" - span.getTag(Tags.HTTP_STATUS) == expectedHttpStatus - - where: - httpStatus | expectedHttpStatus - null | null - "" | null - "500" | 500 - 500 | 500 - 600 | null - } - - def "test normalization does not implicitly convert span type"() { - setup: - def originalSpanType = UTF8BytesString.create("a UTF8 span type") - tracer.buildSpan("datadog", "my-operation-name") - .withSpanType(originalSpanType) - .start().finish() - - when: - writer.waitForTraces(1) - - then: - def trace = writer.firstTrace() - trace.size() == 1 - - def span = trace[0] - span.type == originalSpanType - } - - def "test default env setting"() { - setup: - tracer.buildSpan("datadog", "my-operation-name").start().finish() - writer.waitForTraces(1) - - expect: - def trace = writer.firstTrace() - trace.size() == 1 - - def span = trace[0] - - span.getTag("env") == "none" - } -} diff --git a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/ddintake/DDIntakeTrackTypeResolverTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/common/writer/ddintake/DDIntakeTrackTypeResolverTest.groovy deleted file mode 100644 index d13bff4e1d9..00000000000 --- a/dd-trace-core/src/test/groovy/datadog/trace/common/writer/ddintake/DDIntakeTrackTypeResolverTest.groovy +++ /dev/null @@ -1,24 +0,0 @@ -package datadog.trace.common.writer.ddintake - -import datadog.trace.api.Config -import datadog.trace.api.intake.TrackType -import datadog.trace.test.util.DDSpecification - -class DDIntakeTrackTypeResolverTest extends DDSpecification { - - def "should return the correct TrackType"() { - setup: - Config config = Mock(Config) - config.ciVisibilityEnabled >> ciVisibilityEnabled - config.ciVisibilityAgentlessEnabled >> ciVisibilityAgentlessEnabled - - expect: - DDIntakeTrackTypeResolver.resolve(config) == expectedTrackType - - where: - ciVisibilityEnabled | ciVisibilityAgentlessEnabled | expectedTrackType - false | false | TrackType.NOOP - true | false | TrackType.CITESTCYCLE - true | true | TrackType.CITESTCYCLE - } -} diff --git a/dd-trace-core/src/test/java/datadog/trace/common/writer/DDAgentApiTest.java b/dd-trace-core/src/test/java/datadog/trace/common/writer/DDAgentApiTest.java new file mode 100644 index 00000000000..4e0abf5fbab --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/common/writer/DDAgentApiTest.java @@ -0,0 +1,791 @@ +package datadog.trace.common.writer; + +import static datadog.trace.api.ProtocolVersion.V0_5; +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static java.util.Collections.singletonMap; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import datadog.communication.ddagent.DDAgentFeaturesDiscovery; +import datadog.communication.http.OkHttpUtils; +import datadog.communication.serialization.ByteBufferConsumer; +import datadog.communication.serialization.FlushingBuffer; +import datadog.communication.serialization.msgpack.MsgPackWriter; +import datadog.metrics.api.Monitoring; +import datadog.metrics.api.statsd.StatsDClient; +import datadog.metrics.impl.MonitoringImpl; +import datadog.trace.agent.test.server.http.JavaTestHttpServer; +import datadog.trace.api.Config; +import datadog.trace.api.ProcessTags; +import datadog.trace.api.ProtocolVersion; +import datadog.trace.api.config.OtlpConfig; +import datadog.trace.bootstrap.instrumentation.api.InstrumentationTags; +import datadog.trace.common.sampling.RateByServiceTraceSampler; +import datadog.trace.common.writer.RemoteApi.Response; +import datadog.trace.common.writer.ddagent.DDAgentApi; +import datadog.trace.common.writer.ddagent.TraceMapper; +import datadog.trace.common.writer.ddagent.TraceMapperV0_4; +import datadog.trace.common.writer.ddagent.TraceMapperV0_5; +import datadog.trace.common.writer.ddagent.TraceMapperV1; +import datadog.trace.core.DDCoreJavaSpecification; +import datadog.trace.core.DDSpan; +import datadog.trace.core.DDSpanContext; +import datadog.trace.core.propagation.PropagationTags; +import datadog.trace.test.junit.utils.tabletest.TableTestTypeConverters; +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.msgpack.jackson.dataformat.MessagePackFactory; +import org.tabletest.junit.TableTest; +import org.tabletest.junit.TypeConverterSources; + +@Timeout(20) +@TypeConverterSources(TableTestTypeConverters.class) +public class DDAgentApiTest extends DDCoreJavaSpecification { + + static final Monitoring monitoring = new MonitoringImpl(StatsDClient.NO_OP, 1, TimeUnit.SECONDS); + static final ObjectMapper mapper = new ObjectMapper(new MessagePackFactory()); + + // --- Helper: create a minimal agent server responding 200 to PUT latestVersion --- + + static JavaTestHttpServer newAgent(String latestVersion) { + return JavaTestHttpServer.httpServer( + s -> + s.handlers( + h -> + h.put( + latestVersion, + api -> { + if (!"application/msgpack".equals(api.getRequest().getContentType())) { + api.getResponse() + .status(400) + .send("wrong type: " + api.getRequest().getContentType()); + } else if (api.getRequest().getContentLength() <= 0) { + api.getResponse().status(400).send("no content"); + } else { + api.getResponse().status(200).send(); + } + }))); + } + + // --- Tests --- + + @TableTest({ + "scenario | agentVersion | protocolVersion", + "v0.3 traces | 'v0.3/traces' | V0_4 ", + "v0.4 traces | 'v0.4/traces' | V0_4 ", + "v0.5 traces | 'v0.5/traces' | V0_5 ", + "v1.0 traces | 'v1.0/traces' | V1_0 " + }) + void testSendingAnEmptyListOfTracesReturnsNoErrors( + String agentVersion, ProtocolVersion protocolVersion) { + JavaTestHttpServer agent = newAgent(agentVersion); + try { + DDAgentApi client = createAgentApi(agent.getAddress().toString(), protocolVersion).api; + Payload payload = prepareTraces(agentVersion, emptyList()); + Response response = client.sendSerializedTraces(payload); + assertTrue(response.success()); + assertTrue(response.status().isPresent()); + assertEquals(200, response.status().getAsInt()); + assertEquals("/" + agentVersion, agent.getLastRequest().getPath()); + } finally { + agent.close(); + } + } + + @Test + void testResponseBodyPropagatedInCaseOfNon200Response() { + JavaTestHttpServer agent = + JavaTestHttpServer.httpServer( + s -> + s.handlers( + h -> + h.put( + "v0.4/traces", + api -> api.getResponse().status(400).send("Test error")))); + try { + DDAgentApi client = createAgentApi(agent.getAddress().toString()).api; + Payload payload = prepareTraces("v0.4/traces", emptyList()); + Response clientResponse = client.sendSerializedTraces(payload); + assertFalse(clientResponse.success()); + assertTrue(clientResponse.status().isPresent()); + assertEquals(400, clientResponse.status().getAsInt()); + assertEquals("Test error", clientResponse.response()); + } finally { + agent.close(); + } + } + + @Test + void testNon200Response() { + JavaTestHttpServer agent = + JavaTestHttpServer.httpServer( + s -> + s.handlers( + h -> { + h.put("v0.4/traces", api -> api.getResponse().status(404).send()); + h.put("v0.3/traces", api -> api.getResponse().status(404).send()); + })); + try { + DDAgentApi client = createAgentApi(agent.getAddress().toString()).api; + Payload payload = prepareTraces("v0.3/traces", emptyList()); + Response clientResponse = client.sendSerializedTraces(payload); + assertFalse(clientResponse.success()); + assertTrue(clientResponse.status().isPresent()); + assertEquals(404, clientResponse.status().getAsInt()); + assertEquals("/v0.3/traces", agent.getLastRequest().getPath()); + } finally { + agent.close(); + } + } + + @Test + void testContentIsSentAsMsgpackEmptyTraces() throws IOException { + String agentVersion = "v0.3/traces"; + List> traces = emptyList(); + JavaTestHttpServer agent = + JavaTestHttpServer.httpServer( + s -> s.handlers(h -> h.put(agentVersion, api -> api.getResponse().send()))); + try { + DDAgentApi client = createAgentApi(agent.getAddress().toString()).api; + Payload payload = prepareTraces(agentVersion, traces); + assertTrue(client.sendSerializedTraces(payload).success()); + assertEquals("application/msgpack", agent.getLastRequest().getContentType()); + assertEquals( + "true", agent.getLastRequest().getHeaders().get("Datadog-Client-Computed-Top-Level")); + assertEquals("java", agent.getLastRequest().getHeaders().get("Datadog-Meta-Lang")); + assertEquals( + System.getProperty("java.version", "unknown"), + agent.getLastRequest().getHeaders().get("Datadog-Meta-Lang-Version")); + assertEquals( + "Stubbed-Test-Version", + agent.getLastRequest().getHeaders().get("Datadog-Meta-Tracer-Version")); + assertEquals( + String.valueOf(traces.size()), + agent.getLastRequest().getHeaders().get("X-Datadog-Trace-Count")); + assertEquals( + String.valueOf(payload.droppedTraces()), + agent.getLastRequest().getHeaders().get("Datadog-Client-Dropped-P0-Traces")); + assertEquals( + String.valueOf(payload.droppedSpans()), + agent.getLastRequest().getHeaders().get("Datadog-Client-Dropped-P0-Spans")); + assertEquals(emptyList(), convertList(agentVersion, agent.getLastRequest().getBody())); + } finally { + agent.close(); + } + } + + @Test + void testContentIsSentAsMsgpackServiceSpan() throws IOException { + String agentVersion = "v0.4/traces"; + DDSpan span = + buildSpan( + 1L, + "service.name", + "my-service", + PropagationTags.factory() + .fromHeaderValue(PropagationTags.HeaderType.DATADOG, "_dd.p.usr=123")); + span.finish(); + setDurationNano(span, 10L); + List> traces = singletonList(singletonList(span)); + + JavaTestHttpServer agent = + JavaTestHttpServer.httpServer( + s -> s.handlers(h -> h.put(agentVersion, api -> api.getResponse().send()))); + try { + DDAgentApi client = createAgentApi(agent.getAddress().toString()).api; + Payload payload = prepareTraces(agentVersion, traces); + assertTrue(client.sendSerializedTraces(payload).success()); + assertEquals("application/msgpack", agent.getLastRequest().getContentType()); + assertEquals( + "true", agent.getLastRequest().getHeaders().get("Datadog-Client-Computed-Top-Level")); + assertEquals("java", agent.getLastRequest().getHeaders().get("Datadog-Meta-Lang")); + assertEquals( + System.getProperty("java.version", "unknown"), + agent.getLastRequest().getHeaders().get("Datadog-Meta-Lang-Version")); + assertEquals( + "Stubbed-Test-Version", + agent.getLastRequest().getHeaders().get("Datadog-Meta-Tracer-Version")); + assertEquals( + String.valueOf(traces.size()), + agent.getLastRequest().getHeaders().get("X-Datadog-Trace-Count")); + assertEquals( + String.valueOf(payload.droppedTraces()), + agent.getLastRequest().getHeaders().get("Datadog-Client-Dropped-P0-Traces")); + assertEquals( + String.valueOf(payload.droppedSpans()), + agent.getLastRequest().getHeaders().get("Datadog-Client-Dropped-P0-Spans")); + + Map meta = new TreeMap<>(); + meta.put("thread.name", Thread.currentThread().getName()); + meta.put("_dd.p.usr", "123"); + meta.put("_dd.p.dm", "-1"); + meta.put("_dd.p.ksr", "1"); + meta.put("_dd.svc_src", "m"); + if (Config.get().isExperimentalPropagateProcessTagsEnabled() + && ProcessTags.getTagsForSerialization() != null) { + meta.put("_dd.tags.process", ProcessTags.getTagsForSerialization().toString()); + } + Map metrics = new TreeMap<>(); + metrics.put(DDSpanContext.PRIORITY_SAMPLING_KEY, 1); + metrics.put(InstrumentationTags.DD_TOP_LEVEL.toString(), 1); + metrics.put(RateByServiceTraceSampler.SAMPLING_AGENT_RATE, 1.0); + metrics.put("thread.id", Thread.currentThread().getId()); + Map spanMap = new TreeMap<>(); + spanMap.put("duration", 10); + spanMap.put("error", 0); + spanMap.put("meta", meta); + spanMap.put("metrics", metrics); + spanMap.put("name", "fakeOperation"); + spanMap.put("parent_id", 0); + spanMap.put("resource", "fakeResource"); + spanMap.put("service", "my-service"); + spanMap.put("span_id", 1); + spanMap.put("start", 1000); + spanMap.put("trace_id", 1); + spanMap.put("type", "fakeType"); + List>> expectedRequestBody = singletonList(singletonList(spanMap)); + assertDeepEquals( + expectedRequestBody, convertList(agentVersion, agent.getLastRequest().getBody())); + } finally { + agent.close(); + } + } + + @Test + void testContentIsSentAsMsgpackResourceSpan() throws IOException { + String agentVersion = "v0.4/traces"; + DDSpan span = + buildSpan( + 100L, + "resource.name", + "my-resource", + PropagationTags.factory() + .fromHeaderValue(PropagationTags.HeaderType.DATADOG, "_dd.p.usr=123")); + span.finish(); + setDurationNano(span, 10L); + List> traces = singletonList(singletonList(span)); + + JavaTestHttpServer agent = + JavaTestHttpServer.httpServer( + s -> s.handlers(h -> h.put(agentVersion, api -> api.getResponse().send()))); + try { + DDAgentApi client = createAgentApi(agent.getAddress().toString()).api; + Payload payload = prepareTraces(agentVersion, traces); + assertTrue(client.sendSerializedTraces(payload).success()); + assertEquals("application/msgpack", agent.getLastRequest().getContentType()); + assertEquals( + "true", agent.getLastRequest().getHeaders().get("Datadog-Client-Computed-Top-Level")); + assertEquals("java", agent.getLastRequest().getHeaders().get("Datadog-Meta-Lang")); + assertEquals( + System.getProperty("java.version", "unknown"), + agent.getLastRequest().getHeaders().get("Datadog-Meta-Lang-Version")); + assertEquals( + "Stubbed-Test-Version", + agent.getLastRequest().getHeaders().get("Datadog-Meta-Tracer-Version")); + assertEquals( + String.valueOf(traces.size()), + agent.getLastRequest().getHeaders().get("X-Datadog-Trace-Count")); + assertEquals( + String.valueOf(payload.droppedTraces()), + agent.getLastRequest().getHeaders().get("Datadog-Client-Dropped-P0-Traces")); + assertEquals( + String.valueOf(payload.droppedSpans()), + agent.getLastRequest().getHeaders().get("Datadog-Client-Dropped-P0-Spans")); + + Map meta = new TreeMap<>(); + meta.put("thread.name", Thread.currentThread().getName()); + meta.put("_dd.p.usr", "123"); + meta.put("_dd.p.dm", "-1"); + meta.put("_dd.p.ksr", "1"); + if (Config.get().isExperimentalPropagateProcessTagsEnabled() + && ProcessTags.getTagsForSerialization() != null) { + meta.put("_dd.tags.process", ProcessTags.getTagsForSerialization().toString()); + } + Map metrics = new TreeMap<>(); + metrics.put(DDSpanContext.PRIORITY_SAMPLING_KEY, 1); + metrics.put(InstrumentationTags.DD_TOP_LEVEL.toString(), 1); + metrics.put(RateByServiceTraceSampler.SAMPLING_AGENT_RATE, 1.0); + metrics.put("thread.id", Thread.currentThread().getId()); + Map spanMap = new TreeMap<>(); + spanMap.put("duration", 10); + spanMap.put("error", 0); + spanMap.put("meta", meta); + spanMap.put("metrics", metrics); + spanMap.put("name", "fakeOperation"); + spanMap.put("parent_id", 0); + spanMap.put("resource", "my-resource"); + spanMap.put("service", "fakeService"); + spanMap.put("span_id", 1); + spanMap.put("start", 100000); + spanMap.put("trace_id", 1); + spanMap.put("type", "fakeType"); + List>> expectedRequestBody = singletonList(singletonList(spanMap)); + assertDeepEquals( + expectedRequestBody, convertList(agentVersion, agent.getLastRequest().getBody())); + } finally { + agent.close(); + } + } + + @TableTest({ + "scenario | agentVersion ", + "v0.3 traces | 'v0.3/traces'", + "v0.4 traces | 'v0.4/traces'", + "v0.5 traces | 'v0.5/traces'" + }) + void testApiResponseListenersSee200Responses(String agentVersion) { + AtomicReference>> agentResponse = new AtomicReference<>(null); + RemoteResponseListener responseListener = + (endpoint, responseJson) -> agentResponse.set(responseJson); + + JavaTestHttpServer agent = + JavaTestHttpServer.httpServer( + s -> + s.handlers( + h -> + h.put( + agentVersion, + api -> { + int status = api.getRequest().getContentLength() > 0 ? 200 : 500; + api.getResponse().status(status).send("{\"hello\":{}}"); + }))); + try { + DDAgentApi client = createAgentApi(agent.getAddress().toString()).api; + client.addResponseListener(responseListener); + List> traces = Arrays.asList(emptyList(), emptyList(), emptyList()); + Payload payload = prepareTraces(agentVersion, traces); + payload.withDroppedTraces(1); + payload.withDroppedTraces(3); + + client.sendSerializedTraces(payload); + + Map> response = agentResponse.get(); + assertEquals(singletonMap("hello", emptyMap()), response); + assertEquals("java", agent.getLastRequest().getHeaders().get("Datadog-Meta-Lang")); + assertEquals( + System.getProperty("java.version", "unknown"), + agent.getLastRequest().getHeaders().get("Datadog-Meta-Lang-Version")); + assertEquals( + "Stubbed-Test-Version", + agent.getLastRequest().getHeaders().get("Datadog-Meta-Tracer-Version")); + assertEquals("3", agent.getLastRequest().getHeaders().get("X-Datadog-Trace-Count")); + assertEquals( + String.valueOf(payload.droppedTraces()), + agent.getLastRequest().getHeaders().get("Datadog-Client-Dropped-P0-Traces")); + assertEquals( + String.valueOf(payload.droppedSpans()), + agent.getLastRequest().getHeaders().get("Datadog-Client-Dropped-P0-Spans")); + } finally { + agent.close(); + } + } + + @Test + void testApiDowngradesToV3IfV04NotAvailable() { + JavaTestHttpServer v3Agent = + JavaTestHttpServer.httpServer( + s -> + s.handlers( + h -> + h.put( + "v0.3/traces", + api -> { + int status = api.getRequest().getContentLength() > 0 ? 200 : 500; + api.getResponse().status(status).send(); + }))); + try { + DDAgentApi client = createAgentApi(v3Agent.getAddress().toString()).api; + Payload payload = prepareTraces("v0.4/traces", emptyList()); + assertTrue(client.sendSerializedTraces(payload).success()); + assertEquals("/v0.3/traces", v3Agent.getLastRequest().getPath()); + } finally { + v3Agent.close(); + } + } + + @TableTest({ + "scenario | endpointVersion | delayTrace | badPort", + "v0.4 ok | 'v0.4' | 0 | false ", + "v0.3 bad port | 'v0.3' | 0 | true ", + "v0.4 short delay | 'v0.4' | 500 | false ", + "v0.3 long delay | 'v0.3' | 30000 | false " + }) + void testApiDowngradesToV3IfTimeoutExceeded( + String endpointVersion, int delayTrace, boolean badPort) { + JavaTestHttpServer agent = + JavaTestHttpServer.httpServer( + s -> + s.handlers( + h -> { + h.put( + "v0.3/traces", + api -> { + int status = api.getRequest().getContentLength() > 0 ? 200 : 500; + api.getResponse().status(status).send(); + }); + h.put( + "v0.4/traces", + api -> { + if (delayTrace > 0) { + try { + Thread.sleep(delayTrace); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + int status = api.getRequest().getContentLength() > 0 ? 200 : 500; + api.getResponse().status(status).send(); + }); + })); + try { + int port = badPort ? 999 : agent.getAddress().getPort(); + String url = "http://" + agent.getAddress().getHost() + ":" + port; + DDAgentApi client = createAgentApi(url).api; + Payload payload = prepareTraces("v0.4/traces", emptyList()); + Response result = client.sendSerializedTraces(payload); + assertEquals(!badPort, result.success()); + if (!badPort) { + assertEquals("/" + endpointVersion + "/traces", agent.getLastRequest().getPath()); + } + } finally { + agent.close(); + } + } + + // all the tested traces are empty and it just so happens that + // arrays and maps take the same amount of space in messagepack, so + // all the sizes match, except in v0.5 where there is 1 byte for a + // 2 element array header and 1 byte for an empty dictionary + @TableTest({ + "scenario | agentVersion | expectedLength | traceCount", + "v0.4 empty | 'v0.4/traces' | 1 | 0 ", + "v0.4 2 traces | 'v0.4/traces' | 3 | 2 ", + "v0.4 15 traces | 'v0.4/traces' | 16 | 15 ", + "v0.4 16 traces | 'v0.4/traces' | 19 | 16 ", + "v0.4 65535 traces | 'v0.4/traces' | 65538 | 65535 ", + "v0.4 65536 traces | 'v0.4/traces' | 65541 | 65536 ", + "v0.5 empty | 'v0.5/traces' | 3 | 0 ", + "v0.5 2 traces | 'v0.5/traces' | 5 | 2 ", + "v0.5 15 traces | 'v0.5/traces' | 18 | 15 ", + "v0.5 16 traces | 'v0.5/traces' | 21 | 16 ", + "v0.5 65535 traces | 'v0.5/traces' | 65540 | 65535 ", + "v0.5 65536 traces | 'v0.5/traces' | 65543 | 65536 " + }) + void testVerifyContentLength(String agentVersion, long expectedLength, int traceCount) { + List> traces = generateEmptyTraces(traceCount); + AtomicLong receivedContentLength = new AtomicLong(); + JavaTestHttpServer agent = + JavaTestHttpServer.httpServer( + s -> + s.handlers( + h -> + h.put( + agentVersion, + api -> { + receivedContentLength.set(api.getRequest().getContentLength()); + api.getResponse().status(200).send(); + }))); + try { + DDAgentApi client = createAgentApi(agent.getAddress().toString()).api; + Payload payload = prepareTraces(agentVersion, traces); + assertTrue(client.sendSerializedTraces(payload).success()); + assertEquals(expectedLength, receivedContentLength.get()); + } finally { + agent.close(); + } + } + + @Test + void testEmbeddedHttpClientRejectsAsyncRequests() throws Exception { + JavaTestHttpServer agent = newAgent("v0.5/traces"); + try { + AgentApiPair pair = createAgentApi(agent.getAddress().toString()); + pair.discovery.discover(); + Field httpClientField = DDAgentApi.class.getDeclaredField("httpClient"); + httpClientField.setAccessible(true); + OkHttpClient httpClient = (OkHttpClient) httpClientField.get(pair.api); + ExecutorService httpExecutorService = httpClient.dispatcher().executorService(); + assertThrows(RejectedExecutionException.class, () -> httpExecutorService.execute(() -> {})); + assertTrue(httpExecutorService.isShutdown()); + } finally { + agent.close(); + } + } + + @Test + void testMetaStructSupportOnTheEncodedSpans() throws IOException { + String agentVersion = "v0.4/traces"; + JavaTestHttpServer agent = + JavaTestHttpServer.httpServer( + s -> s.handlers(h -> h.put(agentVersion, api -> api.getResponse().send()))); + try { + DDAgentApi client = createAgentApi(agent.getAddress().toString()).api; + DDSpan span = buildSpan(1L, "fakeType", Collections.emptyMap()); + span.setMetaStruct("meta_1", "Hello World!"); + Map meta2 = new HashMap<>(); + meta2.put("Hello", " World!"); + span.setMetaStruct("meta_2", meta2); + Payload payload = prepareTraces(agentVersion, singletonList(singletonList(span))); + assertTrue(client.sendSerializedTraces(payload).success()); + + List>> body = + convertList(agentVersion, agent.getLastRequest().getBody()); + @SuppressWarnings("unchecked") + Map metaStruct = (Map) body.get(0).get(0).get("meta_struct"); + assertEquals(2, metaStruct.size()); + assertEquals("Hello World!", mapper.readValue(metaStruct.get("meta_1"), String.class)); + @SuppressWarnings("unchecked") + Map actualMeta2 = mapper.readValue(metaStruct.get("meta_2"), Map.class); + assertEquals(meta2, actualMeta2); + } finally { + agent.close(); + } + } + + @TableTest({ + "scenario | otlpSpanMetrics | nativeMetrics | expectedComputesStats", + "gap case: OTLP span metrics on, native off | true | false | true ", + "neither pipeline computes stats | false | false | false ", + "native stats on (regression guard) | false | true | true ", + "both on | true | true | true " + }) + void testDatadogClientComputedStatsHeaderSetWhenEitherStatsPipelineIsEnabled( + boolean otlpSpanMetrics, boolean nativeMetrics, boolean expectedComputesStats) { + datadog.trace.test.junit.utils.config.WithConfigExtension.injectSysConfig( + OtlpConfig.OTEL_TRACES_SPAN_METRICS_ENABLED, String.valueOf(otlpSpanMetrics)); + + JavaTestHttpServer agent = + JavaTestHttpServer.httpServer( + s -> + s.handlers(h -> h.put("v0.4/traces", api -> api.getResponse().status(200).send()))); + try { + HttpUrl agentUrl = HttpUrl.get(agent.getAddress().toString()); + + // Mock feature discovery so the native-stats pipeline signal can be controlled independently + // of what the (embedded) agent advertises. supportsMetrics() reflects agent-side client + // stats support. + DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); + when(discovery.getTraceEndpoint()).thenReturn("v0.4/traces"); + when(discovery.supportsMetrics()).thenReturn(nativeMetrics); + when(discovery.state()).thenReturn(null); + + OkHttpClient client = OkHttpUtils.buildHttpClient(agentUrl, 1000); + // nativeMetrics is the constructor flag WriterFactory sets from + // Config.isTracerMetricsEnabled(). + DDAgentApi api = new DDAgentApi(client, agentUrl, discovery, monitoring, nativeMetrics); + Payload payload = prepareTraces("v0.4/traces", emptyList()); + + Response clientResponse = api.sendSerializedTraces(payload); + + assertTrue(clientResponse.success()); + assertEquals( + expectedComputesStats, + "true".equals(agent.getLastRequest().getHeaders().get("Datadog-Client-Computed-Stats"))); + } finally { + agent.close(); + } + } + + // --- Inner types --- + + static class AgentApiPair { + final DDAgentFeaturesDiscovery discovery; + final DDAgentApi api; + + AgentApiPair(DDAgentFeaturesDiscovery discovery, DDAgentApi api) { + this.discovery = discovery; + this.api = api; + } + } + + static class TracesCapture implements ByteBufferConsumer { + int traceCount; + ByteBuffer buffer; + + @Override + public void accept(int messageCount, ByteBuffer buffer) { + this.buffer = buffer; + this.traceCount = messageCount; + } + } + + // --- Helper methods --- + + AgentApiPair createAgentApi(String url, ProtocolVersion protocolVersion) { + HttpUrl agentUrl = HttpUrl.get(url); + OkHttpClient client = OkHttpUtils.buildHttpClient(agentUrl, 1000); + DDAgentFeaturesDiscovery discovery = + new DDAgentFeaturesDiscovery(client, monitoring, agentUrl, protocolVersion, true, false); + return new AgentApiPair( + discovery, new DDAgentApi(client, agentUrl, discovery, monitoring, false)); + } + + AgentApiPair createAgentApi(String url) { + return createAgentApi(url, V0_5); + } + + Payload prepareTraces(String agentVersion, List> traces) { + TracesCapture traceCapture = new TracesCapture(); + MsgPackWriter packer = new MsgPackWriter(new FlushingBuffer(1 << 20, traceCapture)); + + TraceMapper traceMapper; + if ("v1.0/traces".equals(agentVersion)) { + traceMapper = new TraceMapperV1(); + } else if ("v0.5/traces".equals(agentVersion)) { + traceMapper = new TraceMapperV0_5(); + } else { + traceMapper = new TraceMapperV0_4(); + } + + for (List trace : traces) { + packer.format(trace, traceMapper); + } + packer.flush(); + + return traceMapper + .newPayload() + .withBody( + traceCapture.traceCount, + traces.isEmpty() ? ByteBuffer.allocate(0) : traceCapture.buffer); + } + + static List>> convertList(String agentVersion, byte[] bytes) + throws IOException { + if ("v0.5/traces".equals(agentVersion)) { + return convertListV5(bytes); + } + List>> returnVal = + mapper.readValue(bytes, new TypeReference>>>() {}); + for (List> trace : returnVal) { + for (TreeMap span : trace) { + @SuppressWarnings("unchecked") + Map meta = (Map) span.get("meta"); + if (meta != null) { + meta.remove("runtime-id"); + meta.remove("language"); + } + } + } + return returnVal; + } + + static List>> convertListV5(byte[] bytes) throws IOException { + List>> traces = + mapper.readValue(bytes, new TypeReference>>>() {}); + List>> maps = new ArrayList<>(traces.size()); + for (List> trace : traces) { + List> mapTrace = new ArrayList<>(); + for (List span : trace) { + TreeMap map = new TreeMap<>(); + if (!span.isEmpty()) { + map.put("service", span.get(0)); + map.put("name", span.get(1)); + map.put("resource", span.get(2)); + map.put("trace_id", span.get(3)); + map.put("span_id", span.get(4)); + map.put("parent_id", span.get(5)); + map.put("start", span.get(6)); + map.put("duration", span.get(7)); + map.put("error", span.get(8)); + map.put("meta", span.get(9)); + map.put("metrics", span.get(10)); + map.put("type", span.get(11)); + + @SuppressWarnings("unchecked") + Map meta = (Map) map.get("meta"); + if (meta != null) { + meta.remove("runtime-id"); + meta.remove("language"); + } + } + mapTrace.add(map); + } + maps.add(mapTrace); + } + return maps; + } + + static List> generateEmptyTraces(int count) { + List> traces = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + traces.add(emptyList()); + } + return traces; + } + + static void setDurationNano(DDSpan span, long duration) { + try { + Field field = DDSpan.class.getDeclaredField("durationNano"); + field.setAccessible(true); + field.setLong(span, duration); + } catch (NoSuchFieldException | IllegalAccessException e) { + Assertions.fail("Could not set durationNano: " + e.getMessage()); + } + } + + @SuppressWarnings("unchecked") + static void assertDeepEquals(Object expected, Object actual) { + if (expected == null && actual == null) { + return; + } + if (expected == null || actual == null) { + Assertions.fail("Expected " + expected + " but got " + actual); + } + if (expected instanceof Map) { + assertInstanceOf(Map.class, actual, "Expected Map but got " + actual.getClass()); + Map expectedMap = (Map) expected; + Map actualMap = (Map) actual; + assertEquals(expectedMap.size(), actualMap.size(), "Map size mismatch"); + for (Map.Entry entry : expectedMap.entrySet()) { + assertTrue(actualMap.containsKey(entry.getKey()), "Missing key: " + entry.getKey()); + assertDeepEquals(entry.getValue(), actualMap.get(entry.getKey())); + } + } else if (expected instanceof List) { + assertInstanceOf(List.class, actual, "Expected List but got " + actual.getClass()); + List expectedList = (List) expected; + List actualList = (List) actual; + assertEquals(expectedList.size(), actualList.size(), "List size mismatch"); + for (int i = 0; i < expectedList.size(); i++) { + assertDeepEquals(expectedList.get(i), actualList.get(i)); + } + } else if (expected instanceof Number && actual instanceof Number) { + if (expected instanceof Float + || expected instanceof Double + || actual instanceof Float + || actual instanceof Double) { + assertEquals(((Number) expected).doubleValue(), ((Number) actual).doubleValue(), 0.0001); + } else { + assertEquals(((Number) expected).longValue(), ((Number) actual).longValue()); + } + } else { + assertEquals(expected, actual); + } + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/common/writer/DDAgentWriterCombinedTest.java b/dd-trace-core/src/test/java/datadog/trace/common/writer/DDAgentWriterCombinedTest.java new file mode 100644 index 00000000000..ae9741fe827 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/common/writer/DDAgentWriterCombinedTest.java @@ -0,0 +1,837 @@ +package datadog.trace.common.writer; + +import static datadog.trace.api.ProtocolVersion.V0_5; +import static datadog.trace.api.config.GeneralConfig.EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED; +import static datadog.trace.common.writer.ddagent.Prioritization.ENSURE_TRACE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import datadog.communication.ddagent.DDAgentFeaturesDiscovery; +import datadog.communication.http.OkHttpUtils; +import datadog.communication.serialization.FlushingBuffer; +import datadog.communication.serialization.msgpack.MsgPackWriter; +import datadog.metrics.api.statsd.StatsDClient; +import datadog.metrics.impl.MonitoringImpl; +import datadog.trace.agent.test.server.http.JavaTestHttpServer; +import datadog.trace.api.Config; +import datadog.trace.api.ProcessTags; +import datadog.trace.common.writer.ddagent.DDAgentApi; +import datadog.trace.common.writer.ddagent.TraceMapper; +import datadog.trace.common.writer.ddagent.TraceMapperV0_4; +import datadog.trace.common.writer.ddagent.TraceMapperV0_5; +import datadog.trace.core.CoreTracer; +import datadog.trace.core.DDCoreJavaSpecification; +import datadog.trace.core.DDSpan; +import datadog.trace.core.monitor.HealthMetrics; +import datadog.trace.core.monitor.TracerHealthMetrics; +import datadog.trace.test.junit.utils.config.WithConfig; +import datadog.trace.test.util.Flaky; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Phaser; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import okhttp3.HttpUrl; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.mockito.Mockito; +import org.tabletest.junit.TableTest; + +@Timeout(value = 10, unit = TimeUnit.SECONDS) +class DDAgentWriterCombinedTest extends DDCoreJavaSpecification { + + // DDAgentWriter default buffer size (matches private DDAgentWriter.BUFFER_SIZE) + private static final int AGENT_WRITER_BUFFER_SIZE = 1024; + + MonitoringImpl monitoring = new MonitoringImpl(StatsDClient.NO_OP, 1, TimeUnit.SECONDS); + Phaser phaser = new Phaser(); + + // Only used to create spans + CoreTracer dummyTracer; + + @BeforeEach + void setup() { + // Register for two threads. + phaser.register(); + phaser.register(); + dummyTracer = tracerBuilder().writer(new ListWriter()).build(); + } + + @AfterEach + void cleanup() { + if (dummyTracer != null) { + dummyTracer.close(); + } + } + + @BeforeEach + void resetProcessTags() { + // Sync ProcessTags.enabled with the current Config (which may be modified by @WithConfig) + ProcessTags.reset(Config.get()); + } + + List createMinimalTrace() { + // Use buildSpan from DDCoreJavaSpecification to create a real DDSpan with minimal fields + DDSpan span = buildSpan(0L, "", Collections.emptyMap()); + return Collections.singletonList(span); + } + + @Test + void noInteractionsBecauseOfInitialFlush() { + DDAgentApi api = Mockito.mock(DDAgentApi.class); + DDAgentWriter writer = + DDAgentWriter.builder() + .agentApi(api) + .traceBufferSize(8) + .monitoring(monitoring) + .flushIntervalMilliseconds(-1) + .build(); + writer.start(); + + writer.flush(); + + // then: 0 * _ (no interactions at all on mocked api) + verifyNoMoreInteractions(api); + + writer.close(); + } + + @TableTest({ + "scenario | agentVersion ", + "v0.3 | 'v0.3/traces'", + "v0.4 | 'v0.4/traces'", + "v0.5 | 'v0.5/traces'" + }) + void testHappyPath(String agentVersion) { + DDAgentApi api = mock(DDAgentApi.class); + DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); + DDAgentWriter writer = + DDAgentWriter.builder() + .featureDiscovery(discovery) + .agentApi(api) + .traceBufferSize(1024) + .monitoring(monitoring) + .flushIntervalMilliseconds(-1) + .build(); + writer.start(); + DDSpan span = (DDSpan) dummyTracer.buildSpan("datadog", "fakeOperation").start(); + List trace = Collections.singletonList(span); + + when(discovery.getTraceEndpoint()).thenReturn(agentVersion); + when(api.sendSerializedTraces(argThat(payload -> payload.traceCount() == 2))) + .thenReturn(RemoteApi.Response.success(200)); + + writer.write(trace); + writer.write(trace); + writer.flush(); + + verify(discovery, times(2)).getTraceEndpoint(); + verify(api, times(1)).sendSerializedTraces(argThat(payload -> payload.traceCount() == 2)); + verifyNoMoreInteractions(api, discovery); + + writer.close(); + } + + @TableTest({ + "scenario | agentVersion ", + "v0.3 | 'v0.3/traces'", + "v0.4 | 'v0.4/traces'", + "v0.5 | 'v0.5/traces'" + }) + void testFloodOfTraces(String agentVersion) { + // bufferSize = 1024; traceCount = 100 (shouldn't trigger payload, but bigger than disruptor + // size) + int bufferSize = 1024; + int traceCount = 100; + + DDAgentApi api = mock(DDAgentApi.class); + DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); + DDAgentWriter writer = + DDAgentWriter.builder() + .featureDiscovery(discovery) + .agentApi(api) + .traceBufferSize(bufferSize) + .monitoring(monitoring) + .flushIntervalMilliseconds(-1) + .build(); + writer.start(); + DDSpan span = (DDSpan) dummyTracer.buildSpan("datadog", "fakeOperation").start(); + List trace = Collections.singletonList(span); + + when(discovery.getTraceEndpoint()).thenReturn(agentVersion); + when(api.sendSerializedTraces(argThat(payload -> payload.traceCount() <= traceCount))) + .thenReturn(RemoteApi.Response.success(200)); + + for (int i = 1; i <= traceCount; i++) { + writer.write(trace); + } + writer.flush(); + + verify(discovery, times(2)).getTraceEndpoint(); + verify(api, times(1)) + .sendSerializedTraces(argThat(payload -> payload.traceCount() <= traceCount)); + verifyNoMoreInteractions(api, discovery); + + writer.close(); + } + + @TableTest({ + "scenario | agentVersion ", + "v0.3 | 'v0.3/traces'", + "v0.4 | 'v0.4/traces'", + "v0.5 | 'v0.5/traces'" + }) + void testFlushByTime(String agentVersion) throws Exception { + HealthMetrics healthMetrics = mock(HealthMetrics.class); + DDAgentApi api = mock(DDAgentApi.class); + DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); + DDAgentWriter writer = + DDAgentWriter.builder() + .featureDiscovery(discovery) + .agentApi(api) + .healthMetrics(healthMetrics) + .monitoring(monitoring) + .flushIntervalMilliseconds(1000) + .build(); + writer.start(); + DDSpan span = (DDSpan) dummyTracer.buildSpan("datadog", "fakeOperation").start(); + List trace = Collections.nCopies(10, span); + + when(discovery.getTraceEndpoint()).thenReturn(agentVersion); + when(api.sendSerializedTraces(argThat(payload -> payload.traceCount() == 5))) + .thenReturn(RemoteApi.Response.success(200)); + + // stub onSend to arrive at phaser + doAnswer( + invocation -> { + phaser.arrive(); + return null; + }) + .when(healthMetrics) + .onSend(anyInt(), anyInt(), any()); + + for (int i = 1; i <= 5; i++) { + writer.write(trace); + } + phaser.awaitAdvanceInterruptibly(phaser.arriveAndDeregister()); + + verify(discovery, times(2)).getTraceEndpoint(); + verify(healthMetrics, times(1)).onSerialize(anyInt()); + verify(api, times(1)).sendSerializedTraces(argThat(payload -> payload.traceCount() == 5)); + // _ * healthMetrics.onPublish(_, _) means any number, so don't verify count + verify(healthMetrics).onSend(anyInt(), anyInt(), any()); + verifyNoMoreInteractions(api, discovery); + + writer.close(); + } + + @Timeout(value = 30, unit = TimeUnit.SECONDS) + @WithConfig(key = EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED, value = "false") + @TableTest({ + "scenario | agentVersion ", + "v0.3 | 'v0.3/traces'", + "v0.4 | 'v0.4/traces'", + "v0.5 | 'v0.5/traces'" + }) + void testDefaultBufferSizeFor(String agentVersion) { + // setup: disable process tags since they are only written on the first span + // and it will break the trace size estimation + List minimalTrace = createMinimalTrace(); + DDAgentApi api = mock(DDAgentApi.class); + DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); + DDAgentWriter writer = + DDAgentWriter.builder() + .featureDiscovery(discovery) + .agentApi(api) + .traceBufferSize(AGENT_WRITER_BUFFER_SIZE) + .prioritization(ENSURE_TRACE) + .monitoring(monitoring) + .flushIntervalMilliseconds(-1) + .build(); + writer.start(); + + TraceMapper mapper = + agentVersion.equals("v0.5/traces") ? new TraceMapperV0_5() : new TraceMapperV0_4(); + int traceSize = calculateSize(minimalTrace, mapper); + int maxedPayloadTraceCount = (mapper.messageBufferSize() / traceSize); + + when(discovery.getTraceEndpoint()).thenReturn(agentVersion); + when(api.sendSerializedTraces( + argThat(payload -> payload != null && payload.traceCount() == maxedPayloadTraceCount))) + .thenReturn(RemoteApi.Response.success(200)); + when(api.sendSerializedTraces(argThat(payload -> payload != null && payload.traceCount() == 1))) + .thenReturn(RemoteApi.Response.success(200)); + + for (int i = 0; i <= maxedPayloadTraceCount; i++) { + writer.write(minimalTrace); + } + writer.flush(); + + verify(discovery, times(2)).getTraceEndpoint(); + verify(api, times(1)) + .sendSerializedTraces( + argThat(payload -> payload != null && payload.traceCount() == maxedPayloadTraceCount)); + verify(api, times(1)) + .sendSerializedTraces(argThat(payload -> payload != null && payload.traceCount() == 1)); + verifyNoMoreInteractions(api, discovery); + + writer.close(); + } + + @Test + void checkThatThereAreNoInteractionsAfterClose() { + HealthMetrics healthMetrics = mock(HealthMetrics.class); + DDAgentApi api = mock(DDAgentApi.class); + DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); + DDAgentWriter writer = + DDAgentWriter.builder() + .featureDiscovery(discovery) + .agentApi(api) + .healthMetrics(healthMetrics) + .monitoring(monitoring) + .build(); + writer.start(); + // Clear invocations from start() (Spock only counts interactions in when: blocks) + clearInvocations(healthMetrics, api, discovery); + + writer.close(); + writer.write(Collections.emptyList()); + writer.flush(); + + // then: this will be checked during flushing + verify(healthMetrics, times(1)).onFailedPublish(anyInt(), anyInt()); + verify(healthMetrics, times(1)).onFlush(any(Boolean.class)); + verify(healthMetrics, times(1)).onShutdown(any(Boolean.class)); + verify(healthMetrics, times(1)).close(); + verifyNoMoreInteractions(healthMetrics, api, discovery); + + writer.close(); + } + + @TableTest({ + "scenario | agentVersion ", + "v0.3 | 'v0.3/traces'", + "v0.4 | 'v0.4/traces'", + "v0.5 | 'v0.5/traces'" + }) + void monitorHappyPath(String agentVersion) { + HealthMetrics healthMetrics = mock(HealthMetrics.class); + List minimalTrace = createMinimalTrace(); + + // DQH -- need to set-up a dummy agent for the final send callback to work + JavaTestHttpServer agent = + JavaTestHttpServer.httpServer( + server -> + server.handlers( + h -> h.put(agentVersion, api -> api.getResponse().status(200).send()))); + try { + HttpUrl agentUrl = HttpUrl.get(agent.getAddress()); + okhttp3.OkHttpClient client = OkHttpUtils.buildHttpClient(agentUrl, 1000); + DDAgentFeaturesDiscovery discovery = + new DDAgentFeaturesDiscovery(client, monitoring, agentUrl, V0_5, true, false); + DDAgentApi api = new DDAgentApi(client, agentUrl, discovery, monitoring, true); + DDAgentWriter writer = + DDAgentWriter.builder() + .featureDiscovery(discovery) + .agentApi(api) + .monitoring(monitoring) + .healthMetrics(healthMetrics) + .build(); + + // start + writer.start(); + + verify(healthMetrics, times(1)).onStart((int) writer.getCapacity()); + + // write and flush + writer.write(minimalTrace); + writer.flush(); + + verify(healthMetrics, times(1)).onPublish(any(), anyInt()); + verify(healthMetrics, times(1)).onSerialize(anyInt()); + verify(healthMetrics, times(1)).onFlush(false); + verify(healthMetrics, times(1)) + .onSend( + anyInt(), + anyInt(), + argThat( + response -> + response.success() + && response.status().isPresent() + && response.status().getAsInt() == 200)); + + writer.close(); + + verify(healthMetrics, times(1)).onShutdown(true); + } finally { + agent.close(); + } + } + + @TableTest({ + "scenario | agentVersion ", + "v0.3 | 'v0.3/traces'", + "v0.4 | 'v0.4/traces'", + "v0.5 | 'v0.5/traces'" + }) + void monitorAgentReturnsError(String agentVersion) { + HealthMetrics healthMetrics = mock(HealthMetrics.class); + List minimalTrace = createMinimalTrace(); + + // DQH -- need to set-up a dummy agent for the final send callback to work + final boolean[] first = {true}; + JavaTestHttpServer agent = + JavaTestHttpServer.httpServer( + server -> + server.handlers( + h -> + h.put( + agentVersion, + api -> { + // DQH - DDApi sniffs for end point existence, so respond with 200 the + // first time + if (first[0]) { + api.getResponse().status(200).send(); + first[0] = false; + } else { + api.getResponse().status(500).send(); + } + }))); + try { + HttpUrl agentUrl = HttpUrl.get(agent.getAddress()); + okhttp3.OkHttpClient client = OkHttpUtils.buildHttpClient(agentUrl, 1000); + DDAgentFeaturesDiscovery discovery = + new DDAgentFeaturesDiscovery(client, monitoring, agentUrl, V0_5, true, false); + DDAgentApi api = new DDAgentApi(client, agentUrl, discovery, monitoring, true); + DDAgentWriter writer = + DDAgentWriter.builder() + .featureDiscovery(discovery) + .agentApi(api) + .monitoring(monitoring) + .healthMetrics(healthMetrics) + .build(); + + // start + writer.start(); + + verify(healthMetrics, times(1)).onStart((int) writer.getCapacity()); + + // write and flush + writer.write(minimalTrace); + writer.flush(); + + verify(healthMetrics, times(1)).onPublish(any(), anyInt()); + verify(healthMetrics, times(1)).onSerialize(anyInt()); + verify(healthMetrics, times(1)).onFlush(false); + verify(healthMetrics, times(1)) + .onFailedSend( + anyInt(), + anyInt(), + argThat( + response -> + !response.success() + && response.status().isPresent() + && response.status().getAsInt() == 500)); + + writer.close(); + + verify(healthMetrics, times(1)).onShutdown(true); + } finally { + agent.close(); + } + } + + @TableTest({ + "scenario | agentVersion ", + "v0.3 | 'v0.3/traces'", + "v0.4 | 'v0.4/traces'", + "v0.5 | 'v0.5/traces'" + }) + void unreachableAgentTest(String agentVersion) { + HealthMetrics healthMetrics = mock(HealthMetrics.class); + List minimalTrace = createMinimalTrace(); + DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); + when(discovery.getTraceEndpoint()).thenReturn(agentVersion); + DDAgentApi api = mock(DDAgentApi.class); + // simulating a communication failure to a server + when(api.sendSerializedTraces(any())) + .thenReturn(RemoteApi.Response.failed(new IOException("comm error"))); + + DDAgentWriter writer = + DDAgentWriter.builder() + .featureDiscovery(discovery) + .agentApi(api) + .monitoring(monitoring) + .healthMetrics(healthMetrics) + .build(); + + // start + writer.start(); + + verify(healthMetrics, times(1)).onStart((int) writer.getCapacity()); + + // write and flush + writer.write(minimalTrace); + writer.flush(); + + // then: if we know there's no agent, we'll drop the traces before serialising them + // but we also know that there's nowhere to send health metrics to + verify(healthMetrics, times(1)).onPublish(any(), anyInt()); + verify(healthMetrics, times(1)).onFlush(false); + + writer.close(); + + verify(healthMetrics, times(1)).onShutdown(true); + } + + @Flaky("If execution is too slow, the http client timeout may trigger") + @TableTest({ + "scenario | agentVersion ", + "v0.3 | 'v0.3/traces'", + "v0.4 | 'v0.4/traces'", + "v0.5 | 'v0.5/traces'" + }) + void slowResponseTest(String agentVersion) throws Exception { + int numWritten = 0; + AtomicInteger numFlushes = new AtomicInteger(0); + AtomicInteger numPublished = new AtomicInteger(0); + AtomicInteger numFailedPublish = new AtomicInteger(0); + AtomicInteger numRequests = new AtomicInteger(0); + AtomicInteger numFailedRequests = new AtomicInteger(0); + + Semaphore responseSemaphore = new Semaphore(1); + + // Need to set-up a dummy agent for the final send callback to work + JavaTestHttpServer agent = + JavaTestHttpServer.httpServer( + server -> + server.handlers( + h -> + h.put( + agentVersion, + api -> { + responseSemaphore.acquire(); + try { + api.getResponse().status(200).send(); + } finally { + responseSemaphore.release(); + } + }))); + + // This test focuses just on failed publish, so not verifying every callback + HealthMetrics healthMetrics = mock(HealthMetrics.class); + doAnswer( + invocation -> { + numPublished.incrementAndGet(); + return null; + }) + .when(healthMetrics) + .onPublish(any(), anyInt()); + doAnswer( + invocation -> { + numFailedPublish.incrementAndGet(); + return null; + }) + .when(healthMetrics) + .onFailedPublish(anyInt(), anyInt()); + doAnswer( + invocation -> { + numFlushes.incrementAndGet(); + return null; + }) + .when(healthMetrics) + .onFlush(any(Boolean.class)); + doAnswer( + invocation -> { + numRequests.incrementAndGet(); + return null; + }) + .when(healthMetrics) + .onSend(anyInt(), anyInt(), any()); + doAnswer( + invocation -> { + numFailedRequests.incrementAndGet(); + return null; + }) + .when(healthMetrics) + .onFailedSend(anyInt(), anyInt(), any()); + + int bufferSize = 16; + List minimalTrace = createMinimalTrace(); + DDAgentWriter writer = + DDAgentWriter.builder() + .traceAgentProtocolVersion(V0_5) + .traceAgentPort(agent.getAddress().getPort()) + .monitoring(monitoring) + .healthMetrics(healthMetrics) + .traceBufferSize(bufferSize) + .build(); + writer.start(); + + // gate responses + responseSemaphore.acquire(); + + try { + // when: write a single trace and flush + // with responseSemaphore held, the response is blocked but may still time out + writer.write(minimalTrace); + numWritten += 1; + + // sanity check coordination mechanism of test + // release to allow response to be generated + responseSemaphore.release(); + writer.flush(); + + // reacquire semaphore to stall further responses + responseSemaphore.acquire(); + + assertEquals(0, numFailedPublish.get()); + assertEquals(numWritten, numPublished.get()); + assertEquals(numWritten, numPublished.get() + numFailedPublish.get()); + assertEquals(1, numFlushes.get()); + + // when: send many traces to fill the sender queue... + // loop until outstanding requests > finished requests + while (writer.traceProcessingWorker.getRemainingCapacity() > 0 + || numFailedPublish.get() == 0) { + writer.write(minimalTrace); + numWritten += 1; + } + + assertTrue(numFailedPublish.get() > 0); + assertEquals(numWritten, numPublished.get() + numFailedPublish.get()); + + // with both disruptor & queue full, should reject everything + int expectedRejects = 100; + for (int i = 1; i <= expectedRejects; i++) { + writer.write(minimalTrace); + numWritten += 1; + } + + assertEquals(numWritten, numPublished.get() + numFailedPublish.get()); + } finally { + responseSemaphore.release(); + writer.close(); + agent.close(); + } + } + + @TableTest({ + "scenario | agentVersion ", + "v0.3 | 'v0.3/traces'", + "v0.4 | 'v0.4/traces'", + "v0.5 | 'v0.5/traces'" + }) + void multiThreaded(String agentVersion) throws Exception { + AtomicInteger numPublished = new AtomicInteger(0); + AtomicInteger numFailedPublish = new AtomicInteger(0); + AtomicInteger numRepSent = new AtomicInteger(0); + + List minimalTrace = createMinimalTrace(); + + // Need to set-up a dummy agent for the final send callback to work + JavaTestHttpServer agent = + JavaTestHttpServer.httpServer( + server -> + server.handlers( + h -> h.put(agentVersion, api -> api.getResponse().status(200).send()))); + + // This test focuses just on failed publish, so not verifying every callback + HealthMetrics healthMetrics = mock(HealthMetrics.class); + doAnswer( + invocation -> { + numPublished.incrementAndGet(); + return null; + }) + .when(healthMetrics) + .onPublish(any(), anyInt()); + doAnswer( + invocation -> { + numFailedPublish.incrementAndGet(); + return null; + }) + .when(healthMetrics) + .onFailedPublish(anyInt(), anyInt()); + doAnswer( + invocation -> { + int repCount = invocation.getArgument(0); + numRepSent.addAndGet(repCount); + return null; + }) + .when(healthMetrics) + .onSend(anyInt(), anyInt(), any()); + + DDAgentWriter writer = + DDAgentWriter.builder() + .traceAgentProtocolVersion(V0_5) + .traceAgentPort(agent.getAddress().getPort()) + .monitoring(monitoring) + .healthMetrics(healthMetrics) + .build(); + writer.start(); + + try { + Runnable producer = + () -> { + for (int i = 1; i <= 100; i++) { + writer.write(minimalTrace); + } + }; + + Thread t1 = new Thread(producer); + t1.start(); + + Thread t2 = new Thread(producer); + t2.start(); + + t1.join(); + t2.join(); + + writer.flush(); + + // then: conditions.eventually { assert numPublished.get() == 200 && numRepSent.get() == 200 } + int totalTraces = 100 + 100; + long deadline = System.currentTimeMillis() + 5000; + while (System.currentTimeMillis() < deadline) { + if (numPublished.get() == totalTraces && numRepSent.get() == totalTraces) { + break; + } + Thread.sleep(50); + } + assertEquals(totalTraces, numPublished.get()); + assertEquals(totalTraces, numRepSent.get()); + } finally { + writer.close(); + agent.close(); + } + } + + @TableTest({ + "scenario | agentVersion ", + "v0.3 | 'v0.3/traces'", + "v0.4 | 'v0.4/traces'", + "v0.5 | 'v0.5/traces'" + }) + void statsdSuccess(String agentVersion) { + AtomicInteger numTracesAccepted = new AtomicInteger(0); + AtomicInteger numRequests = new AtomicInteger(0); + AtomicInteger numResponses = new AtomicInteger(0); + + List minimalTrace = createMinimalTrace(); + + // Need to set-up a dummy agent for the final send callback to work + JavaTestHttpServer agent = + JavaTestHttpServer.httpServer( + server -> + server.handlers( + h -> h.put(agentVersion, api -> api.getResponse().status(200).send()))); + + HealthMetrics healthMetrics = mock(HealthMetrics.class); + doAnswer( + invocation -> { + numTracesAccepted.incrementAndGet(); + return null; + }) + .when(healthMetrics) + .onPublish(any(), anyInt()); + doAnswer( + invocation -> { + numRequests.incrementAndGet(); + numResponses.incrementAndGet(); + return null; + }) + .when(healthMetrics) + .onSend(anyInt(), anyInt(), any()); + DDAgentWriter writer = + DDAgentWriter.builder() + .agentHost(agent.getAddress().getHost()) + .traceAgentProtocolVersion(V0_5) + .traceAgentPort(agent.getAddress().getPort()) + .monitoring(monitoring) + .healthMetrics(healthMetrics) + .build(); + writer.start(); + + try { + writer.write(minimalTrace); + writer.flush(); + + assertEquals(1, numTracesAccepted.get()); + assertEquals(1, numRequests.get()); + assertEquals(1, numResponses.get()); + } finally { + agent.close(); + writer.close(); + } + } + + @Test + void statsdCommFailure() throws Exception { + List minimalTrace = createMinimalTrace(); + + DDAgentApi api = Mockito.mock(DDAgentApi.class); + when(api.sendSerializedTraces(any())) + .thenReturn(RemoteApi.Response.failed(new IOException("comm error"))); + + CountDownLatch latch = new CountDownLatch(2); + StatsDClient statsd = mock(StatsDClient.class); + TracerHealthMetrics healthMetrics = new TracerHealthMetrics(statsd, 100, TimeUnit.MILLISECONDS); + DDAgentWriter writer = + DDAgentWriter.builder() + .traceAgentProtocolVersion(V0_5) + .agentApi(api) + .monitoring(monitoring) + .healthMetrics(healthMetrics) + .build(); + healthMetrics.start(); + writer.start(); + + // stub statsd.count for latch coordination - called with varargs String... tags + doAnswer( + invocation -> { + latch.countDown(); + return null; + }) + .when(statsd) + .count(anyString(), anyLong()); + + writer.write(minimalTrace); + writer.flush(); + latch.await(10, TimeUnit.SECONDS); + + verify(statsd, times(1)).count("api.requests.total", 1L); + verify(statsd, times(0)).incrementCounter("api.responses.total"); + verify(statsd, times(1)).count("api.errors.total", 1L); + + writer.close(); + healthMetrics.close(); + } + + static int calculateSize(List trace, TraceMapper mapper) { + AtomicInteger size = new AtomicInteger(); + MsgPackWriter packer = + new MsgPackWriter( + new FlushingBuffer( + 1024, (messageCount, buffer) -> size.set(buffer.limit() - buffer.position()))); + packer.format(trace, mapper); + packer.flush(); + return size.get(); + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/common/writer/DDAgentWriterTest.java b/dd-trace-core/src/test/java/datadog/trace/common/writer/DDAgentWriterTest.java new file mode 100644 index 00000000000..873f80f9a48 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/common/writer/DDAgentWriterTest.java @@ -0,0 +1,223 @@ +package datadog.trace.common.writer; + +import static datadog.trace.common.writer.ddagent.PrioritizationStrategy.PublishResult.ENQUEUED_FOR_SERIALIZATION; +import static datadog.trace.common.writer.ddagent.PrioritizationStrategy.PublishResult.ENQUEUED_FOR_SINGLE_SPAN_SAMPLING; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import datadog.communication.ddagent.DDAgentFeaturesDiscovery; +import datadog.metrics.api.statsd.StatsDClient; +import datadog.metrics.impl.MonitoringImpl; +import datadog.trace.api.sampling.PrioritySampling; +import datadog.trace.common.writer.ddagent.DDAgentApi; +import datadog.trace.common.writer.ddagent.DDAgentMapperDiscovery; +import datadog.trace.common.writer.ddagent.PrioritizationStrategy.PublishResult; +import datadog.trace.core.CoreTracer; +import datadog.trace.core.DDCoreJavaSpecification; +import datadog.trace.core.DDSpan; +import datadog.trace.core.monitor.HealthMetrics; +import datadog.trace.core.propagation.PropagationTags; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.tabletest.junit.TableTest; + +class DDAgentWriterTest extends DDCoreJavaSpecification { + + HealthMetrics monitor = mock(HealthMetrics.class); + TraceProcessingWorker worker = mock(TraceProcessingWorker.class); + DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); + DDAgentApi api = mock(DDAgentApi.class); + MonitoringImpl monitoring = new MonitoringImpl(StatsDClient.NO_OP, 1, SECONDS); + PayloadDispatcherImpl dispatcher = + new PayloadDispatcherImpl(new DDAgentMapperDiscovery(discovery), api, monitor, monitoring); + DDAgentWriter writer = new DDAgentWriter(worker, dispatcher, monitor, 1, SECONDS, false); + + // Only used to create spans + CoreTracer dummyTracer = tracerBuilder().writer(new ListWriter()).build(); + + @AfterEach + void cleanup() { + writer.close(); + dummyTracer.close(); + } + + @Test + void testWriterBuilder() { + DDAgentWriter builtWriter = DDAgentWriter.builder().build(); + + assertNotNull(builtWriter); + } + + @Test + void testWriterStart() { + int capacity = 5; + + when(worker.getCapacity()).thenReturn(capacity); + writer.start(); + + verify(monitor).start(); + verify(worker).start(); + verify(worker).getCapacity(); + verify(monitor).onStart(capacity); + verifyNoMoreInteractions(monitor, worker, discovery, api); + } + + @Test + void testWriterStartClosed() { + writer.close(); + clearInvocations(monitor, worker, discovery, api); + + writer.start(); + + verifyNoMoreInteractions(monitor, worker, discovery, api); + } + + @Test + void testWriterFlush() { + when(worker.flush(1, SECONDS)).thenReturn(true, false); + + // first flush succeeds + writer.flush(); + + // monitor is notified + verify(worker).flush(1, SECONDS); + verify(monitor).onFlush(false); + verifyNoMoreInteractions(monitor, worker, discovery, api); + + clearInvocations(monitor, worker, discovery, api); + + // second flush returns false + writer.flush(); + + // no additional monitor notifications + verify(worker).flush(1, SECONDS); + verifyNoMoreInteractions(monitor, worker, discovery, api); + } + + @Test + void testWriterFlushClosed() { + writer.close(); + clearInvocations(monitor, worker, discovery, api); + + writer.flush(); + + verifyNoMoreInteractions(monitor, worker, discovery, api); + } + + @Test + void testWriterWritePublishSucceeds() { + List trace = + Collections.singletonList( + (DDSpan) dummyTracer.buildSpan("datadog", "fakeOperation").start()); + + // publish succeeds + when(worker.publish(any(), anyInt(), eq(trace))).thenReturn(ENQUEUED_FOR_SERIALIZATION); + writer.write(trace); + + // monitor is notified of successful publication + verify(worker).publish(any(), anyInt(), eq(trace)); + verify(monitor).onPublish(any(), anyInt()); + verifyNoMoreInteractions(monitor, worker, discovery, api); + } + + @Test + void testWriterWritePublishForSingleSpanSampling() { + List trace = + Collections.singletonList( + (DDSpan) dummyTracer.buildSpan("datadog", "fakeOperation").start()); + + // publish succeeds (single span sampling) + when(worker.publish(any(), anyInt(), eq(trace))).thenReturn(ENQUEUED_FOR_SINGLE_SPAN_SAMPLING); + writer.write(trace); + + // monitor should not call onPublish for single span sampling + verify(worker).publish(any(), anyInt(), eq(trace)); + verifyNoMoreInteractions(monitor, worker, discovery, api); + } + + @TableTest({ + "scenario | publishResult ", + "buffer overflow | DROPPED_BUFFER_OVERFLOW", + "dropped by policy | DROPPED_BY_POLICY " + }) + void testWriterWritePublishFails(PublishResult publishResult) { + List trace = + Collections.singletonList( + (DDSpan) dummyTracer.buildSpan("datadog", "fakeOperation").start()); + + // publish fails + when(worker.publish(any(), anyInt(), eq(trace))).thenReturn(publishResult); + writer.write(trace); + + // monitor is notified of unsuccessful publication + verify(worker).publish(any(), anyInt(), eq(trace)); + verify(monitor).onFailedPublish(anyInt(), anyInt()); + verifyNoMoreInteractions(monitor, worker, discovery, api); + } + + @Test + void testEmptyTracesShouldBeReportedAsFailures() { + // trace is empty + writer.write(Collections.emptyList()); + + // monitor is notified of unsuccessful publication + verify(monitor).onFailedPublish(anyInt(), anyInt()); + verifyNoMoreInteractions(monitor, worker, discovery, api); + } + + @Test + void testWriterWriteClosed() { + writer.close(); + clearInvocations(monitor, worker, discovery, api); + List trace = + Collections.singletonList( + (DDSpan) dummyTracer.buildSpan("datadog", "fakeOperation").start()); + + writer.write(trace); + + verify(monitor).onFailedPublish(anyInt(), anyInt()); + verifyNoMoreInteractions(monitor, worker, discovery, api); + } + + @TableTest({ + "scenario | publishResult ", + "dropped by policy | DROPPED_BY_POLICY ", + "buffer overflow | DROPPED_BUFFER_OVERFLOW" + }) + void testDroppedTraceIsCounted(PublishResult publishResult) { + // setup - use local mocks to avoid interference with instance-level mocks + TraceProcessingWorker localWorker = mock(TraceProcessingWorker.class); + HealthMetrics localMonitor = mock(HealthMetrics.class); + PayloadDispatcherImpl localDispatcher = mock(PayloadDispatcherImpl.class); + DDAgentWriter localWriter = + new DDAgentWriter(localWorker, localDispatcher, localMonitor, 1, SECONDS, false); + + DDSpan p0 = newSpan(); + p0.setSamplingPriority(PrioritySampling.SAMPLER_DROP); + List trace = Arrays.asList(p0, newSpan()); + + when(localWorker.publish(eq(trace.get(0)), eq((int) PrioritySampling.SAMPLER_DROP), eq(trace))) + .thenReturn(publishResult); + localWriter.write(trace); + + verify(localWorker) + .publish(eq(trace.get(0)), eq((int) PrioritySampling.SAMPLER_DROP), eq(trace)); + verify(localDispatcher).onDroppedTrace(trace.size()); + } + + DDSpan newSpan() { + // Use the UNSET-priority variant so setSamplingPriority() can change the priority later + return buildSpan(0L, "test.tag", "test.value", PropagationTags.factory().empty()); + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/common/writer/DDIntakeWriterCombinedTest.java b/dd-trace-core/src/test/java/datadog/trace/common/writer/DDIntakeWriterCombinedTest.java new file mode 100644 index 00000000000..c53286cb106 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/common/writer/DDIntakeWriterCombinedTest.java @@ -0,0 +1,826 @@ +package datadog.trace.common.writer; + +import static datadog.trace.common.writer.ddagent.Prioritization.ENSURE_TRACE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import datadog.communication.http.OkHttpUtils; +import datadog.communication.serialization.FlushingBuffer; +import datadog.communication.serialization.msgpack.MsgPackWriter; +import datadog.metrics.api.statsd.StatsDClient; +import datadog.metrics.impl.MonitoringImpl; +import datadog.trace.agent.test.server.http.JavaTestHttpServer; +import datadog.trace.api.civisibility.CiVisibilityWellKnownTags; +import datadog.trace.api.intake.TrackType; +import datadog.trace.common.writer.ddintake.DDIntakeApi; +import datadog.trace.common.writer.ddintake.DDIntakeMapperDiscovery; +import datadog.trace.core.DDCoreJavaSpecification; +import datadog.trace.core.DDSpan; +import datadog.trace.core.monitor.HealthMetrics; +import datadog.trace.core.monitor.TracerHealthMetrics; +import datadog.trace.test.util.Flaky; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Phaser; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +@Timeout(value = 10, unit = TimeUnit.SECONDS) +class DDIntakeWriterCombinedTest extends DDCoreJavaSpecification { + + private static final CiVisibilityWellKnownTags wellKnownTags = + new CiVisibilityWellKnownTags( + "my-runtime-id", + "my-env", + "my-language", + "my-runtime-name", + "my-runtime-version", + "my-runtime-vendor", + "my-os-arch", + "my-os-platform", + "my-os-version", + "false"); + + MonitoringImpl monitoring = new MonitoringImpl(StatsDClient.NO_OP, 1, TimeUnit.SECONDS); + Phaser phaser = new Phaser(); + + // Only used to create spans + datadog.trace.core.CoreTracer dummyTracer; + + @BeforeEach + void setup() { + // Register for two threads. + phaser.register(); + phaser.register(); + dummyTracer = tracerBuilder().writer(new ListWriter()).build(); + } + + @AfterEach + void cleanup() { + if (dummyTracer != null) { + dummyTracer.close(); + } + } + + List createMinimalTrace() { + // Use buildSpan from DDCoreJavaSpecification to create a real DDSpan with minimal fields + DDSpan span = buildSpan(0L, "", Collections.emptyMap()); + return Collections.singletonList(span); + } + + @Test + void noInteractionsBecauseOfInitialFlush() { + DDIntakeApi api = mock(DDIntakeApi.class); + DDIntakeWriter writer = + DDIntakeWriter.builder() + .addTrack(TrackType.NOOP, api) + .traceBufferSize(8) + .monitoring(monitoring) + .flushIntervalMilliseconds(-1) + .alwaysFlush(false) + .build(); + writer.start(); + // Clear setup-time interactions (e.g. isCompressionEnabled() called during build()) + clearInvocations(api); + + writer.flush(); + + // then: 0 * _ (no interactions at all on mocked api) + verifyNoMoreInteractions(api); + + writer.close(); + } + + @Test + void testHappyPath() { + TrackType trackType = TrackType.CITESTCYCLE; + DDIntakeApi api = mock(DDIntakeApi.class); + DDIntakeWriter writer = + DDIntakeWriter.builder() + .addTrack(trackType, api) + .traceBufferSize(1024) + .monitoring(monitoring) + .flushIntervalMilliseconds(-1) + .alwaysFlush(false) + .build(); + writer.start(); + // Clear setup-time interactions (e.g. isCompressionEnabled() called during build()) + clearInvocations(api); + DDSpan span = (DDSpan) dummyTracer.buildSpan("datadog", "fakeOperation").start(); + List trace = Collections.singletonList(span); + + doAnswer(invocation -> RemoteApi.Response.success(200)) + .when(api) + .sendSerializedTraces(argThat(payload -> payload.traceCount() == 2)); + + writer.write(trace); + writer.write(trace); + writer.flush(); + + verify(api, times(1)).sendSerializedTraces(argThat(payload -> payload.traceCount() == 2)); + verifyNoMoreInteractions(api); + + writer.close(); + } + + @Test + void testFloodOfTraces() { + // bufferSize = 1024; traceCount = 100 (shouldn't trigger payload, but bigger than disruptor + // size) + int traceCount = 100; + TrackType trackType = TrackType.CITESTCYCLE; + DDIntakeApi api = mock(DDIntakeApi.class); + DDIntakeWriter writer = + DDIntakeWriter.builder() + .addTrack(trackType, api) + .traceBufferSize(1024) + .monitoring(monitoring) + .flushIntervalMilliseconds(-1) + .alwaysFlush(false) + .build(); + writer.start(); + // Clear setup-time interactions (e.g. isCompressionEnabled() called during build()) + clearInvocations(api); + DDSpan span = (DDSpan) dummyTracer.buildSpan("datadog", "fakeOperation").start(); + List trace = Collections.singletonList(span); + + doAnswer(invocation -> RemoteApi.Response.success(200)) + .when(api) + .sendSerializedTraces(argThat(payload -> payload.traceCount() <= traceCount)); + + for (int i = 1; i <= traceCount; i++) { + writer.write(trace); + } + writer.flush(); + + verify(api, times(1)) + .sendSerializedTraces(argThat(payload -> payload.traceCount() <= traceCount)); + verifyNoMoreInteractions(api); + + writer.close(); + } + + @Test + void testFlushByTime() throws Exception { + TrackType trackType = TrackType.CITESTCYCLE; + HealthMetrics healthMetrics = mock(HealthMetrics.class); + DDIntakeApi api = mock(DDIntakeApi.class); + DDIntakeWriter writer = + DDIntakeWriter.builder() + .addTrack(trackType, api) + .healthMetrics(healthMetrics) + .monitoring(monitoring) + .flushIntervalMilliseconds(1000) + .alwaysFlush(false) + .build(); + writer.start(); + // Clear setup-time interactions (e.g. isCompressionEnabled() called during build(), onStart + // from start()) + clearInvocations(api, healthMetrics); + DDSpan span = (DDSpan) dummyTracer.buildSpan("datadog", "fakeOperation").start(); + List trace = Collections.nCopies(10, span); + + doAnswer(invocation -> RemoteApi.Response.success(200)) + .when(api) + .sendSerializedTraces(argThat(payload -> payload.traceCount() == 5)); + + // stub onSend to arrive at phaser + doAnswer( + invocation -> { + phaser.arrive(); + return null; + }) + .when(healthMetrics) + .onSend(anyInt(), anyInt(), any()); + + for (int i = 1; i <= 5; i++) { + writer.write(trace); + } + phaser.awaitAdvanceInterruptibly(phaser.arriveAndDeregister()); + + verify(healthMetrics, times(1)).onSerialize(anyInt()); + verify(api, times(1)).sendSerializedTraces(argThat(payload -> payload.traceCount() == 5)); + // _ * healthMetrics.onPublish(_, _) means any number, so don't verify count + verify(healthMetrics).onSend(anyInt(), anyInt(), any()); + verifyNoMoreInteractions(api); + + writer.close(); + } + + @Timeout(value = 30, unit = TimeUnit.SECONDS) + @Test + void testDefaultBufferSizeForCitestcycle() { + TrackType trackType = TrackType.CITESTCYCLE; + List minimalTrace = createMinimalTrace(); + DDIntakeApi api = mock(DDIntakeApi.class); + DDIntakeWriter writer = + DDIntakeWriter.builder() + .addTrack(trackType, api) + .wellKnownTags(wellKnownTags) + .traceBufferSize(1024) + .prioritization(ENSURE_TRACE) + .monitoring(monitoring) + .flushIntervalMilliseconds(-1) + .alwaysFlush(false) + .build(); + writer.start(); + // Clear setup-time interactions (e.g. isCompressionEnabled() called during build()) + clearInvocations(api); + + DDIntakeMapperDiscovery discovery = + new DDIntakeMapperDiscovery(trackType, wellKnownTags, false); + discovery.discover(); + RemoteMapper mapper = discovery.getMapper(); + int traceSize = calculateSize(minimalTrace, mapper); + int maxedPayloadTraceCount = (mapper.messageBufferSize() / traceSize); + + doAnswer(invocation -> RemoteApi.Response.success(200)) + .when(api) + .sendSerializedTraces( + argThat(payload -> payload != null && payload.traceCount() == maxedPayloadTraceCount)); + doAnswer(invocation -> RemoteApi.Response.success(200)) + .when(api) + .sendSerializedTraces(argThat(payload -> payload != null && payload.traceCount() == 1)); + + for (int i = 0; i <= maxedPayloadTraceCount; i++) { + writer.write(minimalTrace); + } + writer.flush(); + + verify(api, times(1)) + .sendSerializedTraces( + argThat(payload -> payload != null && payload.traceCount() == maxedPayloadTraceCount)); + verify(api, times(1)) + .sendSerializedTraces(argThat(payload -> payload != null && payload.traceCount() == 1)); + verifyNoMoreInteractions(api); + + writer.close(); + } + + @Test + void checkThatThereAreNoInteractionsAfterClose() { + TrackType trackType = TrackType.CITESTCYCLE; + DDIntakeApi api = mock(DDIntakeApi.class); + HealthMetrics healthMetrics = mock(HealthMetrics.class); + DDIntakeWriter writer = + DDIntakeWriter.builder() + .addTrack(trackType, api) + .healthMetrics(healthMetrics) + .monitoring(monitoring) + .alwaysFlush(false) + .build(); + writer.start(); + // Clear setup-time interactions (e.g. isCompressionEnabled() called during build(), onStart + // from start()) + clearInvocations(api, healthMetrics); + + writer.close(); + writer.write(Collections.emptyList()); + writer.flush(); + + // then: this will be checked during flushing + verify(healthMetrics, times(1)).onFailedPublish(anyInt(), anyInt()); + verify(healthMetrics, times(1)).onFlush(any(Boolean.class)); + verify(healthMetrics, times(1)).onShutdown(any(Boolean.class)); + verify(healthMetrics, times(1)).close(); + verifyNoMoreInteractions(healthMetrics, api); + + writer.close(); + } + + @Test + void monitorHappyPath() { + TrackType trackType = TrackType.CITESTCYCLE; + String apiVersion = "v2"; + HealthMetrics healthMetrics = mock(HealthMetrics.class); + List minimalTrace = createMinimalTrace(); + String path = buildIntakePath(trackType, apiVersion); + + JavaTestHttpServer intake = + JavaTestHttpServer.httpServer( + server -> + server.handlers(h -> h.post(path, api -> api.getResponse().status(200).send()))); + try { + HttpUrl hostUrl = HttpUrl.get(intake.getAddress()); + OkHttpClient httpClient = OkHttpUtils.buildHttpClient(hostUrl, 1000); + DDIntakeApi api = + DDIntakeApi.builder() + .hostUrl(hostUrl) + .httpClient(httpClient) + .apiKey("my-api-key") + .trackType(trackType) + .build(); + DDIntakeWriter writer = + DDIntakeWriter.builder() + .addTrack(trackType, api) + .healthMetrics(healthMetrics) + .monitoring(monitoring) + .alwaysFlush(false) + .build(); + + // start + writer.start(); + + verify(healthMetrics, times(1)).onStart((int) writer.getCapacity()); + + // write and flush + writer.write(minimalTrace); + writer.flush(); + + verify(healthMetrics, times(1)).onPublish(any(), anyInt()); + verify(healthMetrics, times(1)).onSerialize(anyInt()); + verify(healthMetrics, times(1)).onFlush(false); + verify(healthMetrics, times(1)) + .onSend( + anyInt(), + anyInt(), + argThat( + response -> + response.success() + && response.status().isPresent() + && response.status().getAsInt() == 200)); + + writer.close(); + + verify(healthMetrics, times(1)).onShutdown(true); + } finally { + intake.close(); + } + } + + @Test + void monitorIntakeReturnsError() { + TrackType trackType = TrackType.CITESTCYCLE; + String apiVersion = "v2"; + HealthMetrics healthMetrics = mock(HealthMetrics.class); + List minimalTrace = createMinimalTrace(); + String path = buildIntakePath(trackType, apiVersion); + + JavaTestHttpServer intake = + JavaTestHttpServer.httpServer( + server -> + server.handlers(h -> h.post(path, api -> api.getResponse().status(500).send()))); + try { + HttpUrl hostUrl = HttpUrl.get(intake.getAddress()); + okhttp3.OkHttpClient httpClient = OkHttpUtils.buildHttpClient(hostUrl, 1000); + DDIntakeApi api = + DDIntakeApi.builder() + .hostUrl(hostUrl) + .httpClient(httpClient) + .apiKey("my-api-key") + .trackType(trackType) + .build(); + DDIntakeWriter writer = + DDIntakeWriter.builder() + .addTrack(trackType, api) + .healthMetrics(healthMetrics) + .monitoring(monitoring) + .alwaysFlush(false) + .build(); + + // start + writer.start(); + + verify(healthMetrics, times(1)).onStart((int) writer.getCapacity()); + + // write and flush + writer.write(minimalTrace); + writer.flush(); + + verify(healthMetrics, times(1)).onPublish(any(), anyInt()); + verify(healthMetrics, times(1)).onSerialize(anyInt()); + verify(healthMetrics, times(1)).onFlush(false); + verify(healthMetrics, times(1)) + .onFailedSend( + anyInt(), + anyInt(), + argThat( + response -> + !response.success() + && response.status().isPresent() + && response.status().getAsInt() == 500)); + + writer.close(); + + verify(healthMetrics, times(1)).onShutdown(true); + } finally { + intake.close(); + } + } + + @Test + void unreachableIntakeTest() { + TrackType trackType = TrackType.CITESTCYCLE; + HealthMetrics healthMetrics = mock(HealthMetrics.class); + List minimalTrace = createMinimalTrace(); + DDIntakeApi api = mock(DDIntakeApi.class); + // simulating a communication failure to a server + doAnswer(invocation -> RemoteApi.Response.failed(new IOException("comm error"))) + .when(api) + .sendSerializedTraces(any()); + + DDIntakeWriter writer = + DDIntakeWriter.builder() + .addTrack(trackType, api) + .monitoring(monitoring) + .healthMetrics(healthMetrics) + .alwaysFlush(false) + .build(); + + // start + writer.start(); + + verify(healthMetrics, times(1)).onStart((int) writer.getCapacity()); + + // write and flush + writer.write(minimalTrace); + writer.flush(); + + // then: if we know there's no agent, we'll drop the traces before serialising them + // but we also know that there's nowhere to send health metrics to + verify(healthMetrics, times(1)).onPublish(any(), anyInt()); + verify(healthMetrics, times(1)).onFlush(false); + + writer.close(); + + verify(healthMetrics, times(1)).onShutdown(true); + } + + @Flaky("If execution is too slow, the http client timeout may trigger") + @Test + void slowResponseTest() throws Exception { + int numWritten = 0; + AtomicInteger numFlushes = new AtomicInteger(0); + AtomicInteger numPublished = new AtomicInteger(0); + AtomicInteger numFailedPublish = new AtomicInteger(0); + AtomicInteger numRequests = new AtomicInteger(0); + AtomicInteger numFailedRequests = new AtomicInteger(0); + + Semaphore responseSemaphore = new Semaphore(1); + + TrackType trackType = TrackType.CITESTCYCLE; + String apiVersion = "v2"; + int bufferSize = 16; + List minimalTrace = createMinimalTrace(); + String path = buildIntakePath(trackType, apiVersion); + + JavaTestHttpServer intake = + JavaTestHttpServer.httpServer( + server -> + server.handlers( + h -> + h.post( + path, + api -> { + responseSemaphore.acquire(); + try { + api.getResponse().status(200).send(); + } finally { + responseSemaphore.release(); + } + }))); + + // This test focuses just on failed publish, so not verifying every callback + HealthMetrics healthMetrics = mock(HealthMetrics.class); + doAnswer( + invocation -> { + numPublished.incrementAndGet(); + return null; + }) + .when(healthMetrics) + .onPublish(any(), anyInt()); + doAnswer( + invocation -> { + numFailedPublish.incrementAndGet(); + return null; + }) + .when(healthMetrics) + .onFailedPublish(anyInt(), anyInt()); + doAnswer( + invocation -> { + numFlushes.incrementAndGet(); + return null; + }) + .when(healthMetrics) + .onFlush(any(Boolean.class)); + doAnswer( + invocation -> { + numRequests.incrementAndGet(); + return null; + }) + .when(healthMetrics) + .onSend(anyInt(), anyInt(), any()); + doAnswer( + invocation -> { + numFailedRequests.incrementAndGet(); + return null; + }) + .when(healthMetrics) + .onFailedSend(anyInt(), anyInt(), any()); + + HttpUrl hostUrl = HttpUrl.get(intake.getAddress()); + okhttp3.OkHttpClient httpClient = OkHttpUtils.buildHttpClient(hostUrl, 1000); + DDIntakeApi api = + DDIntakeApi.builder() + .hostUrl(hostUrl) + .httpClient(httpClient) + .apiKey("my-api-key") + .trackType(trackType) + .build(); + DDIntakeWriter writer = + DDIntakeWriter.builder() + .addTrack(trackType, api) + .healthMetrics(healthMetrics) + .traceBufferSize(bufferSize) + .alwaysFlush(false) + .build(); + writer.start(); + + // gate responses + responseSemaphore.acquire(); + + try { + // sanity check coordination mechanism of test + // release to allow response to be generated + responseSemaphore.release(); + writer.flush(); + + // reacquire semaphore to stall further responses + responseSemaphore.acquire(); + + // when: write a single trace and flush + // with responseSemaphore held, the response is blocked but may still time out + writer.write(minimalTrace); + numWritten += 1; + + assertEquals(0, numFailedPublish.get()); + assertEquals(numWritten, numPublished.get()); + assertEquals(numWritten, numPublished.get() + numFailedPublish.get()); + assertEquals(1, numFlushes.get()); + + // when: send many traces to fill the sender queue... + // loop until outstanding requests > finished requests + while (writer.traceProcessingWorker.getRemainingCapacity() > 0 + || numFailedPublish.get() == 0) { + writer.write(minimalTrace); + numWritten += 1; + } + + assertTrue(numFailedPublish.get() > 0); + assertEquals(numWritten, numPublished.get() + numFailedPublish.get()); + + // with both disruptor & queue full, should reject everything + int expectedRejects = 100; + for (int i = 1; i <= expectedRejects; i++) { + writer.write(minimalTrace); + numWritten += 1; + } + + assertEquals(numWritten, numPublished.get() + numFailedPublish.get()); + } finally { + responseSemaphore.release(); + writer.close(); + intake.close(); + } + } + + @Test + void multiThreaded() throws Exception { + TrackType trackType = TrackType.CITESTCYCLE; + String apiVersion = "v2"; + AtomicInteger numPublished = new AtomicInteger(0); + AtomicInteger numFailedPublish = new AtomicInteger(0); + AtomicInteger numRepSent = new AtomicInteger(0); + + List minimalTrace = createMinimalTrace(); + String path = buildIntakePath(trackType, apiVersion); + + JavaTestHttpServer intake = + JavaTestHttpServer.httpServer( + server -> + server.handlers(h -> h.post(path, api -> api.getResponse().status(200).send()))); + + // This test focuses just on failed publish, so not verifying every callback + HealthMetrics healthMetrics = mock(HealthMetrics.class); + doAnswer( + invocation -> { + numPublished.incrementAndGet(); + return null; + }) + .when(healthMetrics) + .onPublish(any(), anyInt()); + doAnswer( + invocation -> { + numFailedPublish.incrementAndGet(); + return null; + }) + .when(healthMetrics) + .onFailedPublish(anyInt(), anyInt()); + doAnswer( + invocation -> { + int repCount = invocation.getArgument(0); + numRepSent.addAndGet(repCount); + return null; + }) + .when(healthMetrics) + .onSend(anyInt(), anyInt(), any()); + + HttpUrl hostUrl = HttpUrl.get(intake.getAddress()); + okhttp3.OkHttpClient httpClient = OkHttpUtils.buildHttpClient(hostUrl, 1000); + DDIntakeApi api = + DDIntakeApi.builder() + .hostUrl(hostUrl) + .httpClient(httpClient) + .apiKey("my-api-key") + .trackType(trackType) + .build(); + DDIntakeWriter writer = + DDIntakeWriter.builder() + .addTrack(trackType, api) + .monitoring(monitoring) + .healthMetrics(healthMetrics) + .alwaysFlush(false) + .build(); + writer.start(); + + try { + Runnable producer = + () -> { + for (int i = 1; i <= 100; i++) { + writer.write(minimalTrace); + } + }; + + Thread t1 = new Thread(producer); + t1.start(); + + Thread t2 = new Thread(producer); + t2.start(); + + t1.join(); + t2.join(); + + writer.flush(); + + // then: conditions.eventually { assert numPublished.get() == 200 && numRepSent.get() == 200 } + int totalTraces = 100 + 100; + long deadline = System.currentTimeMillis() + 5000; + while (System.currentTimeMillis() < deadline) { + if (numPublished.get() == totalTraces && numRepSent.get() == totalTraces) { + break; + } + Thread.sleep(50); + } + assertEquals(totalTraces, numPublished.get()); + assertEquals(totalTraces, numRepSent.get()); + } finally { + writer.close(); + intake.close(); + } + } + + @Test + void statsdSuccess() { + TrackType trackType = TrackType.CITESTCYCLE; + String apiVersion = "v2"; + AtomicInteger numTracesAccepted = new AtomicInteger(0); + AtomicInteger numRequests = new AtomicInteger(0); + AtomicInteger numResponses = new AtomicInteger(0); + + List minimalTrace = createMinimalTrace(); + String path = buildIntakePath(trackType, apiVersion); + + JavaTestHttpServer intake = + JavaTestHttpServer.httpServer( + server -> + server.handlers(h -> h.post(path, api -> api.getResponse().status(200).send()))); + + HealthMetrics healthMetrics = mock(HealthMetrics.class); + doAnswer( + invocation -> { + numTracesAccepted.incrementAndGet(); + return null; + }) + .when(healthMetrics) + .onPublish(any(), anyInt()); + doAnswer( + invocation -> { + numRequests.incrementAndGet(); + numResponses.incrementAndGet(); + return null; + }) + .when(healthMetrics) + .onSend(anyInt(), anyInt(), any()); + + HttpUrl hostUrl = HttpUrl.get(intake.getAddress()); + okhttp3.OkHttpClient httpClient = OkHttpUtils.buildHttpClient(hostUrl, 1000); + DDIntakeApi api = + DDIntakeApi.builder() + .hostUrl(hostUrl) + .httpClient(httpClient) + .apiKey("my-api-key") + .trackType(trackType) + .build(); + DDIntakeWriter writer = + DDIntakeWriter.builder() + .addTrack(trackType, api) + .monitoring(monitoring) + .healthMetrics(healthMetrics) + .alwaysFlush(false) + .build(); + writer.start(); + + try { + writer.write(minimalTrace); + writer.flush(); + + assertEquals(1, numTracesAccepted.get()); + assertEquals(1, numRequests.get()); + assertEquals(1, numResponses.get()); + } finally { + intake.close(); + writer.close(); + } + } + + @Test + void statsdCommFailure() throws Exception { + TrackType trackType = TrackType.CITESTCYCLE; + List minimalTrace = createMinimalTrace(); + + DDIntakeApi api = mock(DDIntakeApi.class); + doAnswer(invocation -> RemoteApi.Response.failed(new IOException("comm error"))) + .when(api) + .sendSerializedTraces(any()); + + CountDownLatch latch = new CountDownLatch(2); + StatsDClient statsd = mock(StatsDClient.class); + TracerHealthMetrics healthMetrics = new TracerHealthMetrics(statsd, 100, TimeUnit.MILLISECONDS); + DDIntakeWriter writer = + DDIntakeWriter.builder() + .addTrack(trackType, api) + .monitoring(monitoring) + .healthMetrics(healthMetrics) + .alwaysFlush(false) + .build(); + healthMetrics.start(); + writer.start(); + + // Set up stubs with countDown BEFORE the action + doAnswer( + invocation -> { + latch.countDown(); + return null; + }) + .when(statsd) + .count(anyString(), anyLong()); + + writer.write(minimalTrace); + writer.flush(); + latch.await(10, TimeUnit.SECONDS); + + verify(statsd, times(1)).count("api.requests.total", 1L); + verify(statsd, never()).incrementCounter("api.responses.total"); + verify(statsd, times(1)).count("api.errors.total", 1L); + + writer.close(); + healthMetrics.close(); + } + + static String buildIntakePath(TrackType trackType, String apiVersion) { + return String.format("/api/%s/%s", apiVersion, trackType.name().toLowerCase()); + } + + static int calculateSize(List trace, RemoteMapper mapper) { + AtomicInteger size = new AtomicInteger(); + MsgPackWriter packer = + new MsgPackWriter( + new FlushingBuffer( + mapper.messageBufferSize(), + (messageCount, buffer) -> size.set(buffer.limit() - buffer.position()))); + packer.format(trace, mapper); + packer.flush(); + return size.get(); + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/common/writer/DDIntakeWriterTest.java b/dd-trace-core/src/test/java/datadog/trace/common/writer/DDIntakeWriterTest.java new file mode 100644 index 00000000000..eb7f00ac75e --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/common/writer/DDIntakeWriterTest.java @@ -0,0 +1,222 @@ +package datadog.trace.common.writer; + +import static datadog.trace.common.writer.ddagent.PrioritizationStrategy.PublishResult.ENQUEUED_FOR_SERIALIZATION; +import static datadog.trace.common.writer.ddagent.PrioritizationStrategy.PublishResult.ENQUEUED_FOR_SINGLE_SPAN_SAMPLING; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import datadog.communication.ddagent.DDAgentFeaturesDiscovery; +import datadog.metrics.api.statsd.StatsDClient; +import datadog.metrics.impl.MonitoringImpl; +import datadog.trace.api.intake.TrackType; +import datadog.trace.common.writer.ddagent.DDAgentApi; +import datadog.trace.common.writer.ddagent.DDAgentMapperDiscovery; +import datadog.trace.common.writer.ddagent.PrioritizationStrategy.PublishResult; +import datadog.trace.core.CoreTracer; +import datadog.trace.core.DDCoreJavaSpecification; +import datadog.trace.core.DDSpan; +import datadog.trace.core.monitor.HealthMetrics; +import datadog.trace.core.propagation.PropagationTags; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.tabletest.junit.TableTest; + +class DDIntakeWriterTest extends DDCoreJavaSpecification { + + HealthMetrics healthMetrics = mock(HealthMetrics.class); + TraceProcessingWorker worker = mock(TraceProcessingWorker.class); + DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); + DDAgentApi api = mock(DDAgentApi.class); + MonitoringImpl monitoring = new MonitoringImpl(StatsDClient.NO_OP, 1, TimeUnit.SECONDS); + PayloadDispatcherImpl dispatcher = + new PayloadDispatcherImpl( + new DDAgentMapperDiscovery(discovery), api, healthMetrics, monitoring); + DDIntakeWriter writer = new DDIntakeWriter(worker, dispatcher, healthMetrics, false); + + // Only used to create spans + CoreTracer dummyTracer; + + @BeforeEach + void setup() { + dummyTracer = tracerBuilder().writer(new ListWriter()).build(); + } + + @AfterEach + void cleanup() { + writer.close(); + if (dummyTracer != null) { + dummyTracer.close(); + } + } + + @Test + void testWriterBuilder() { + DDIntakeWriter builtWriter = + DDIntakeWriter.builder().addTrack(TrackType.NOOP, mock(RemoteApi.class)).build(); + + assertNotNull(builtWriter); + } + + @Test + void testWriterStart() { + int capacity = 5; + + when(worker.getCapacity()).thenReturn(capacity); + writer.start(); + + verify(healthMetrics).start(); + verify(worker).start(); + verify(worker).getCapacity(); + verify(healthMetrics).onStart(capacity); + verifyNoMoreInteractions(healthMetrics, worker, discovery, api); + } + + @Test + void testWriterFlush() { + when(worker.flush(1, TimeUnit.SECONDS)).thenReturn(true, false); + + // first flush succeeds + writer.flush(); + + // monitor is notified + verify(worker).flush(1, TimeUnit.SECONDS); + verify(healthMetrics).onFlush(false); + verifyNoMoreInteractions(healthMetrics, worker, discovery, api); + + clearInvocations(healthMetrics, worker, discovery, api); + + // second flush returns false + writer.flush(); + + // no additional monitor notifications + verify(worker).flush(1, TimeUnit.SECONDS); + verifyNoMoreInteractions(healthMetrics, worker, discovery, api); + } + + @Test + void testWriterFlushClosed() { + writer.close(); + clearInvocations(healthMetrics, worker, discovery, api); + + writer.flush(); + + verifyNoMoreInteractions(healthMetrics, worker, discovery, api); + } + + @Test + void testWriterWritePublishSucceeds() { + List trace = + Collections.singletonList( + (DDSpan) dummyTracer.buildSpan("datadog", "fakeOperation").start()); + + // publish succeeds + when(worker.publish(any(), anyInt(), eq(trace))).thenReturn(ENQUEUED_FOR_SERIALIZATION); + when(worker.flush(anyLong(), any(TimeUnit.class))).thenReturn(true); + writer.write(trace); + + // monitor is notified of successful publication + verify(worker).publish(any(), anyInt(), eq(trace)); + verify(healthMetrics).onPublish(any(), anyInt()); + verifyNoMoreInteractions(healthMetrics); + } + + @Test + void testWriterWritePublishForSingleSpanSampling() { + List trace = + Collections.singletonList( + (DDSpan) dummyTracer.buildSpan("datadog", "fakeOperation").start()); + + // publish succeeds for single span sampling + when(worker.publish(any(), anyInt(), eq(trace))).thenReturn(ENQUEUED_FOR_SINGLE_SPAN_SAMPLING); + when(worker.flush(anyLong(), any(TimeUnit.class))).thenReturn(true); + writer.write(trace); + + // monitor should not call onPublish for single span sampling + verify(worker).publish(any(), anyInt(), eq(trace)); + verifyNoMoreInteractions(healthMetrics); + } + + @TableTest({ + "scenario | publishResult ", + "buffer overflow | DROPPED_BUFFER_OVERFLOW", + "dropped by policy | DROPPED_BY_POLICY " + }) + void testWriterWritePublishFails(PublishResult publishResult) { + List trace = + Collections.singletonList( + (DDSpan) dummyTracer.buildSpan("datadog", "fakeOperation").start()); + + // publish fails + when(worker.publish(any(), anyInt(), eq(trace))).thenReturn(publishResult); + when(worker.flush(anyLong(), any(TimeUnit.class))).thenReturn(true); + writer.write(trace); + + // monitor is notified of unsuccessful publication + verify(worker).publish(any(), anyInt(), eq(trace)); + verify(healthMetrics).onFailedPublish(anyInt(), eq(1)); + verifyNoMoreInteractions(healthMetrics); + } + + @Test + void testEmptyTracesShouldBeReportedAsFailures() { + // trace is empty + when(worker.flush(anyLong(), any(TimeUnit.class))).thenReturn(true); + writer.write(Collections.emptyList()); + + // monitor is notified of unsuccessful publication + verify(healthMetrics).onFailedPublish(anyInt(), eq(0)); + verifyNoMoreInteractions(healthMetrics); + } + + @Test + void testWriterWriteClosed() { + writer.close(); + clearInvocations(healthMetrics, worker, discovery, api); + List trace = + Collections.singletonList( + (DDSpan) dummyTracer.buildSpan("datadog", "fakeOperation").start()); + + when(worker.flush(anyLong(), any(TimeUnit.class))).thenReturn(true); + writer.write(trace); + + verify(healthMetrics).onFailedPublish(anyInt(), eq(1)); + verifyNoMoreInteractions(healthMetrics); + } + + @TableTest({ + "scenario | publishResult ", + "dropped by policy | DROPPED_BY_POLICY ", + "buffer overflow | DROPPED_BUFFER_OVERFLOW" + }) + void testDroppedTraceIsCounted(PublishResult publishResult) { + // setup - use local mocks + PayloadDispatcherImpl localDispatcher = mock(PayloadDispatcherImpl.class); + DDIntakeWriter localWriter = new DDIntakeWriter(worker, localDispatcher, healthMetrics, true); + + DDSpan p0 = newSpan(); + List trace = java.util.Arrays.asList(p0, newSpan()); + + when(worker.publish(eq(trace.get(0)), anyInt(), eq(trace))).thenReturn(publishResult); + localWriter.write(trace); + + verify(worker).publish(eq(trace.get(0)), anyInt(), eq(trace)); + verify(localDispatcher).onDroppedTrace(trace.size()); + } + + DDSpan newSpan() { + // Use the UNSET-priority variant so setSamplingPriority() can change the priority later + return buildSpan(0L, "test.tag", "test.value", PropagationTags.factory().empty()); + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/common/writer/MultiWriterTest.java b/dd-trace-core/src/test/java/datadog/trace/common/writer/MultiWriterTest.java new file mode 100644 index 00000000000..3dd2819f39d --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/common/writer/MultiWriterTest.java @@ -0,0 +1,79 @@ +package datadog.trace.common.writer; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import datadog.trace.core.DDSpan; +import datadog.trace.test.util.DDJavaSpecification; +import java.util.LinkedList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class MultiWriterTest extends DDJavaSpecification { + + @Test + void testThatMultiWriterDelegatesToAll() { + Writer[] writers = new Writer[3]; + Writer mockW1 = mock(Writer.class); + Writer mockW2 = mock(Writer.class); + writers[0] = mockW1; + // null in position 1 to check that we skip that + writers[2] = mockW2; + MultiWriter writer = new MultiWriter(writers); + List trace = new LinkedList<>(); + + writer.start(); + + verify(mockW1).start(); + verify(mockW2).start(); + verifyNoMoreInteractions(mockW1, mockW2); + clearInvocations(mockW1, mockW2); + + writer.write(trace); + + verify(mockW1).write(trace); + verify(mockW2).write(trace); + verifyNoMoreInteractions(mockW1, mockW2); + clearInvocations(mockW1, mockW2); + + // flush (both return true) + when(mockW1.flush()).thenReturn(true); + when(mockW2.flush()).thenReturn(true); + boolean flushed = writer.flush(); + + verify(mockW1).flush(); + verify(mockW2).flush(); + verifyNoMoreInteractions(mockW1, mockW2); + assertTrue(flushed); + clearInvocations(mockW1, mockW2); + + // flush (one returns false) + when(mockW1.flush()).thenReturn(true); + when(mockW2.flush()).thenReturn(false); + boolean notFlushed = writer.flush(); + + verify(mockW1).flush(); + verify(mockW2).flush(); + verifyNoMoreInteractions(mockW1, mockW2); + assertFalse(notFlushed); + clearInvocations(mockW1, mockW2); + + writer.close(); + + verify(mockW1).close(); + verify(mockW2).close(); + verifyNoMoreInteractions(mockW1, mockW2); + clearInvocations(mockW1, mockW2); + + writer.incrementDropCounts(0); + + verify(mockW1).incrementDropCounts(0); + verify(mockW2).incrementDropCounts(0); + verifyNoMoreInteractions(mockW1, mockW2); + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/common/writer/PayloadDispatcherImplTest.java b/dd-trace-core/src/test/java/datadog/trace/common/writer/PayloadDispatcherImplTest.java new file mode 100644 index 00000000000..cf756003e7c --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/common/writer/PayloadDispatcherImplTest.java @@ -0,0 +1,233 @@ +package datadog.trace.common.writer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.intThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import datadog.communication.ddagent.DDAgentFeaturesDiscovery; +import datadog.metrics.api.statsd.StatsDClient; +import datadog.metrics.impl.MonitoringImpl; +import datadog.trace.api.DDSpanId; +import datadog.trace.api.DDTraceId; +import datadog.trace.api.datastreams.NoopPathwayContext; +import datadog.trace.api.sampling.PrioritySampling; +import datadog.trace.common.writer.ddagent.DDAgentApi; +import datadog.trace.common.writer.ddagent.DDAgentMapperDiscovery; +import datadog.trace.core.CoreTracer; +import datadog.trace.core.DDSpan; +import datadog.trace.core.DDSpanContext; +import datadog.trace.core.PendingTrace; +import datadog.trace.core.monitor.HealthMetrics; +import datadog.trace.core.propagation.PropagationTags; +import datadog.trace.test.util.DDJavaSpecification; +import java.lang.reflect.Constructor; +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.tabletest.junit.TableTest; + +class PayloadDispatcherImplTest extends DDJavaSpecification { + + static final MonitoringImpl monitoring = + new MonitoringImpl(StatsDClient.NO_OP, 1, TimeUnit.SECONDS); + + @Timeout(180) + // spotless:off + @TableTest({ + "scenario | traceEndpoint", + "v0.5 | 'v0.5/traces'", + "v0.4 | 'v0.4/traces'" + }) + // spotless:on + void testFlushAutomaticallyWhenDataLimitIsBreached(String traceEndpoint) throws Exception { + AtomicBoolean flushed = new AtomicBoolean(); + HealthMetrics healthMetrics = mock(HealthMetrics.class); + DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); + when(discovery.getTraceEndpoint()).thenReturn(traceEndpoint); + DDAgentApi api = mock(DDAgentApi.class); + when(api.sendSerializedTraces(any())) + .thenAnswer( + inv -> { + flushed.set(true); + return RemoteApi.Response.success(200); + }); + PayloadDispatcherImpl dispatcher = + new PayloadDispatcherImpl( + new DDAgentMapperDiscovery(discovery), api, healthMetrics, monitoring); + List trace = Collections.singletonList(realSpan()); + + while (!flushed.get()) { + dispatcher.addTrace(trace); + } + + // the dispatcher has flushed + assertTrue(flushed.get()); + } + + @TableTest({ + "scenario | traceEndpoint | traceCount", + "v0.4 1 trace | 'v0.4/traces' | 1 ", + "v0.4 10 traces | 'v0.4/traces' | 10 ", + "v0.4 100 traces | 'v0.4/traces' | 100 ", + "v0.5 1 trace | 'v0.5/traces' | 1 ", + "v0.5 10 traces | 'v0.5/traces' | 10 ", + "v0.5 100 traces | 'v0.5/traces' | 100 " + }) + void testShouldFlushBufferOnDemand(String traceEndpoint, int traceCount) throws Exception { + HealthMetrics healthMetrics = mock(HealthMetrics.class); + DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); + DDAgentApi api = mock(DDAgentApi.class); + when(discovery.getTraceEndpoint()).thenReturn(traceEndpoint); + when(api.sendSerializedTraces(any())).thenReturn(RemoteApi.Response.success(200)); + PayloadDispatcherImpl dispatcher = + new PayloadDispatcherImpl( + new DDAgentMapperDiscovery(discovery), api, healthMetrics, monitoring); + List trace = Collections.singletonList(realSpan()); + + for (int i = 0; i < traceCount; ++i) { + dispatcher.addTrace(trace); + } + dispatcher.flush(); + + verify(discovery, org.mockito.Mockito.times(2)).getTraceEndpoint(); + verify(healthMetrics).onSerialize(intThat(size -> size > 0)); + verify(api).sendSerializedTraces(argThat(p -> p.traceCount() == traceCount)); + } + + @TableTest({ + "scenario | traceEndpoint | traceCount", + "v0.4 1 trace | 'v0.4/traces' | 1 ", + "v0.4 10 traces | 'v0.4/traces' | 10 ", + "v0.4 100 traces | 'v0.4/traces' | 100 ", + "v0.5 1 trace | 'v0.5/traces' | 1 ", + "v0.5 10 traces | 'v0.5/traces' | 10 ", + "v0.5 100 traces | 'v0.5/traces' | 100 " + }) + void testShouldReportFailedRequestToMonitor(String traceEndpoint, int traceCount) + throws Exception { + HealthMetrics healthMetrics = mock(HealthMetrics.class); + DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); + DDAgentApi api = mock(DDAgentApi.class); + when(discovery.getTraceEndpoint()).thenReturn(traceEndpoint); + when(api.sendSerializedTraces(any())).thenReturn(RemoteApi.Response.failed(400)); + PayloadDispatcherImpl dispatcher = + new PayloadDispatcherImpl( + new DDAgentMapperDiscovery(discovery), api, healthMetrics, monitoring); + List trace = Collections.singletonList(realSpan()); + + for (int i = 0; i < traceCount; ++i) { + dispatcher.addTrace(trace); + } + dispatcher.flush(); + + verify(discovery, org.mockito.Mockito.times(2)).getTraceEndpoint(); + verify(healthMetrics).onSerialize(intThat(size -> size > 0)); + verify(api).sendSerializedTraces(argThat(p -> p.traceCount() == traceCount)); + } + + @Test + void testShouldDropTraceWhenThereIsNoAgentConnectivity() throws Exception { + HealthMetrics healthMetrics = mock(HealthMetrics.class); + DDAgentApi api = mock(DDAgentApi.class); + DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); + when(discovery.getTraceEndpoint()).thenReturn(null); + PayloadDispatcherImpl dispatcher = + new PayloadDispatcherImpl( + new DDAgentMapperDiscovery(discovery), api, healthMetrics, monitoring); + List trace = Collections.singletonList(realSpan()); + + dispatcher.addTrace(trace); + + verify(healthMetrics).onFailedPublish(eq((int) PrioritySampling.UNSET), anyInt()); + } + + @Test + void testTraceAndSpanCountsAreResetAfterAccess() { + HealthMetrics healthMetrics = mock(HealthMetrics.class); + DDAgentApi api = mock(DDAgentApi.class); + DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); + when(discovery.getTraceEndpoint()).thenReturn("v0.4/traces"); + PayloadDispatcherImpl dispatcher = + new PayloadDispatcherImpl( + new DDAgentMapperDiscovery(discovery), api, healthMetrics, monitoring); + + // add traces and dropped counts + dispatcher.addTrace(Collections.emptyList()); + dispatcher.onDroppedTrace(20); + dispatcher.onDroppedTrace(2); + Payload payload = dispatcher.newPayload(1, ByteBuffer.allocate(0)); + + // dropped counts are accumulated + assertEquals(22, payload.droppedSpans()); + assertEquals(2, payload.droppedTraces()); + + // create another payload + Payload newPayload = dispatcher.newPayload(1, ByteBuffer.allocate(0)); + + // counts are reset after access + assertEquals(0, newPayload.droppedSpans()); + assertEquals(0, newPayload.droppedTraces()); + } + + DDSpan realSpan() throws Exception { + // getTracer() and mapServiceName() are package-private in TraceCollector; use a custom + // Answer to handle them at runtime without compile-time accessibility issues + PendingTrace trace = + mock( + PendingTrace.class, + invocation -> { + Class returnType = invocation.getMethod().getReturnType(); + if (CoreTracer.class.isAssignableFrom(returnType)) { + // Use RETURNS_DEFAULTS so getTagInterceptor() returns null (matching Groovy Stub + // behavior) + return mock(CoreTracer.class); + } + if (returnType == String.class) { + Object[] args = invocation.getArguments(); + // mapServiceName(String) - return the argument unchanged + if (args.length > 0 && args[0] instanceof String) { + return args[0]; + } + return ""; + } + return org.mockito.Mockito.RETURNS_DEFAULTS.answer(invocation); + }); + DDSpanContext context = + new DDSpanContext( + DDTraceId.ONE, + 1L, + DDSpanId.ZERO, + null, + "", + "", + "", + PrioritySampling.UNSET, + "", + Collections.emptyMap(), + false, + "", + 0, + trace, + null, + null, + NoopPathwayContext.INSTANCE, + false, + PropagationTags.factory().empty()); + Constructor ctor = + DDSpan.class.getDeclaredConstructor( + String.class, long.class, DDSpanContext.class, List.class); + ctor.setAccessible(true); + return ctor.newInstance("test", 0L, context, null); + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/common/writer/PrioritizationTest.java b/dd-trace-core/src/test/java/datadog/trace/common/writer/PrioritizationTest.java new file mode 100644 index 00000000000..7a9a52cd144 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/common/writer/PrioritizationTest.java @@ -0,0 +1,307 @@ +package datadog.trace.common.writer; + +import static datadog.trace.common.writer.ddagent.PrioritizationStrategy.PublishResult.DROPPED_BUFFER_OVERFLOW; +import static datadog.trace.common.writer.ddagent.PrioritizationStrategy.PublishResult.ENQUEUED_FOR_SERIALIZATION; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import datadog.trace.api.sampling.PrioritySampling; +import datadog.trace.common.writer.ddagent.FlushEvent; +import datadog.trace.common.writer.ddagent.Prioritization; +import datadog.trace.common.writer.ddagent.PrioritizationStrategy; +import datadog.trace.common.writer.ddagent.PrioritizationStrategy.PublishResult; +import datadog.trace.core.DDSpan; +import datadog.trace.test.junit.utils.converter.PrioritySamplingConverter; +import datadog.trace.test.util.DDJavaSpecification; +import java.util.Collections; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.params.converter.ConvertWith; +import org.tabletest.junit.TableTest; + +class PrioritizationTest extends DDJavaSpecification { + + @SuppressWarnings("unchecked") + @TableTest({ + "scenario | primaryFull | priority | primaryOffers | secondaryOffers", + "unset full | true | PrioritySampling.UNSET | 2 | 0 ", + "drop full | true | PrioritySampling.SAMPLER_DROP | 0 | 1 ", + "keep full | true | PrioritySampling.SAMPLER_KEEP | 2 | 0 ", + "drop full 2 | true | PrioritySampling.SAMPLER_DROP | 0 | 1 ", + "user keep full | true | PrioritySampling.USER_KEEP | 2 | 0 ", + "unset not full | false | PrioritySampling.UNSET | 1 | 0 ", + "drop not full | false | PrioritySampling.SAMPLER_DROP | 0 | 1 ", + "keep not full | false | PrioritySampling.SAMPLER_KEEP | 1 | 0 ", + "drop not full 2 | false | PrioritySampling.SAMPLER_DROP | 0 | 1 ", + "user keep not full | false | PrioritySampling.USER_KEEP | 1 | 0 " + }) + void testEnsureTraceStrategyTriesToSendKeptAndUnsetPriorityTracesToPrimaryQueue( + boolean primaryFull, + @ConvertWith(PrioritySamplingConverter.class) int priority, + int primaryOffers, + int secondaryOffers) { + List trace = Collections.emptyList(); + Queue primary = mock(Queue.class); + Queue secondary = mock(Queue.class); + PrioritizationStrategy blocking = + Prioritization.ENSURE_TRACE.create(primary, secondary, null, () -> false); + // stub: first offer returns !primaryFull, second offer returns true + when(primary.offer(trace)).thenReturn(!primaryFull, true); + when(secondary.offer(trace)).thenReturn(true); + + PublishResult publishResult = blocking.publish(mock(DDSpan.class), priority, trace); + + assertEquals(ENQUEUED_FOR_SERIALIZATION, publishResult); + verify(primary, times(primaryOffers)).offer(trace); + verify(secondary, times(secondaryOffers)).offer(trace); + } + + @SuppressWarnings("unchecked") + @TableTest({ + "scenario | priority | primaryOffers | secondaryOffers", + "unset | PrioritySampling.UNSET | 1 | 0 ", + "drop | PrioritySampling.SAMPLER_DROP | 0 | 1 ", + "keep | PrioritySampling.SAMPLER_KEEP | 1 | 0 ", + "drop 2 | PrioritySampling.SAMPLER_DROP | 0 | 1 ", + "user keep | PrioritySampling.USER_KEEP | 1 | 0 " + }) + void testFastLaneStrategySendsKeptAndUnsetPriorityTracesToPrimaryQueue( + @ConvertWith(PrioritySamplingConverter.class) int priority, + int primaryOffers, + int secondaryOffers) { + List trace = Collections.emptyList(); + Queue primary = mock(Queue.class); + Queue secondary = mock(Queue.class); + PrioritizationStrategy fastLane = + Prioritization.FAST_LANE.create(primary, secondary, null, () -> false); + + PublishResult publishResult = fastLane.publish(mock(DDSpan.class), priority, trace); + + assertEquals(DROPPED_BUFFER_OVERFLOW, publishResult); + verify(primary, times(primaryOffers)).offer(trace); + verify(secondary, times(secondaryOffers)).offer(trace); + } + + @SuppressWarnings("unchecked") + @TableTest({ + "scenario | priority | primaryOffers | expectedResult ", + "unset | PrioritySampling.UNSET | 1 | ENQUEUED_FOR_SERIALIZATION", + "drop | PrioritySampling.SAMPLER_DROP | 0 | DROPPED_BY_POLICY ", + "keep | PrioritySampling.SAMPLER_KEEP | 1 | ENQUEUED_FOR_SERIALIZATION", + "drop 2 | PrioritySampling.SAMPLER_DROP | 0 | DROPPED_BY_POLICY ", + "user keep | PrioritySampling.USER_KEEP | 1 | ENQUEUED_FOR_SERIALIZATION" + }) + void testFastLaneWithActiveDroppingPolicySendsKeptAndUnsetTracesToPrimaryQueue( + @ConvertWith(PrioritySamplingConverter.class) int priority, + int primaryOffers, + PublishResult expectedResult) { + List trace = Collections.emptyList(); + Queue primary = mock(Queue.class); + Queue secondary = mock(Queue.class); + PrioritizationStrategy drop = + Prioritization.FAST_LANE.create(primary, secondary, null, () -> true); + when(primary.offer(trace)).thenReturn(true); + + PublishResult publishResult = drop.publish(mock(DDSpan.class), priority, trace); + + assertEquals(expectedResult, publishResult); + verify(primary, times(primaryOffers)).offer(trace); + verify(secondary, never()).offer(trace); + } + + @SuppressWarnings("unchecked") + @TableTest({ + "scenario | strategy ", + "fast lane | FAST_LANE ", + "ensure trace | ENSURE_TRACE" + }) + void testStrategyFlushesPrimaryQueue(Prioritization strategy) { + Queue primary = mock(Queue.class); + Queue secondary = mock(Queue.class); + PrioritizationStrategy prioritizationStrategy = + strategy.create(primary, secondary, null, () -> false); + when(primary.offer(any())).thenReturn(true); + + prioritizationStrategy.flush(100, TimeUnit.MILLISECONDS); + + verify(primary).offer(any(FlushEvent.class)); + verify(secondary, never()).offer(any()); + } + + @SuppressWarnings("unchecked") + @TableTest({ + "scenario | strategy | forceKeep | expectedResult ", + "force keep true fast lane | FAST_LANE | true | ENQUEUED_FOR_SERIALIZATION", + "force keep false fast lane | FAST_LANE | false | DROPPED_BY_POLICY " + }) + void testDropStrategyRespectsForceKeep( + Prioritization strategy, boolean forceKeep, PublishResult expectedResult) { + Queue primary = mock(Queue.class); + PrioritizationStrategy drop = strategy.create(primary, null, null, () -> true); + DDSpan root = mock(DDSpan.class); + List trace = Collections.singletonList(root); + when(root.isForceKeep()).thenReturn(forceKeep); + when(primary.offer(trace)).thenReturn(true); + + PublishResult publishResult = drop.publish(root, PrioritySampling.SAMPLER_DROP, trace); + + assertEquals(expectedResult, publishResult); + verify(root).isForceKeep(); + verify(primary, times(forceKeep ? 1 : 0)).offer(trace); + verifyNoMoreInteractions(root, primary); + } + + @SuppressWarnings("unchecked") + @TableTest({ + "scenario | primaryFull | priority | primaryOffers | singleSpanOffers | singleSpanFull | expectedResult ", + "unset full ss-not-full | true | PrioritySampling.UNSET | 2 | 0 | false | ENQUEUED_FOR_SERIALIZATION ", + "drop full ss-not-full | true | PrioritySampling.SAMPLER_DROP | 0 | 1 | false | ENQUEUED_FOR_SINGLE_SPAN_SAMPLING", + "keep full ss-not-full | true | PrioritySampling.SAMPLER_KEEP | 2 | 0 | false | ENQUEUED_FOR_SERIALIZATION ", + "drop full 2 ss-not-full | true | PrioritySampling.SAMPLER_DROP | 0 | 1 | false | ENQUEUED_FOR_SINGLE_SPAN_SAMPLING", + "ukeep full ss-not-full | true | PrioritySampling.USER_KEEP | 2 | 0 | false | ENQUEUED_FOR_SERIALIZATION ", + "unset nfull ss-not-full | false | PrioritySampling.UNSET | 1 | 0 | false | ENQUEUED_FOR_SERIALIZATION ", + "drop nfull ss-not-full | false | PrioritySampling.SAMPLER_DROP | 0 | 1 | false | ENQUEUED_FOR_SINGLE_SPAN_SAMPLING", + "keep nfull ss-not-full | false | PrioritySampling.SAMPLER_KEEP | 1 | 0 | false | ENQUEUED_FOR_SERIALIZATION ", + "drop nfull 2 ss-not-full | false | PrioritySampling.SAMPLER_DROP | 0 | 1 | false | ENQUEUED_FOR_SINGLE_SPAN_SAMPLING", + "ukeep nfull ss-not-full | false | PrioritySampling.USER_KEEP | 1 | 0 | false | ENQUEUED_FOR_SERIALIZATION ", + "unset full ss-full | true | PrioritySampling.UNSET | 2 | 0 | true | ENQUEUED_FOR_SERIALIZATION ", + "drop full ss-full | true | PrioritySampling.SAMPLER_DROP | 0 | 1 | true | DROPPED_BUFFER_OVERFLOW ", + "keep full ss-full | true | PrioritySampling.SAMPLER_KEEP | 2 | 0 | true | ENQUEUED_FOR_SERIALIZATION ", + "drop full 2 ss-full | true | PrioritySampling.SAMPLER_DROP | 0 | 1 | true | DROPPED_BUFFER_OVERFLOW ", + "ukeep full ss-full | true | PrioritySampling.USER_KEEP | 2 | 0 | true | ENQUEUED_FOR_SERIALIZATION ", + "unset nfull ss-full | false | PrioritySampling.UNSET | 1 | 0 | true | ENQUEUED_FOR_SERIALIZATION ", + "drop nfull ss-full | false | PrioritySampling.SAMPLER_DROP | 0 | 1 | true | DROPPED_BUFFER_OVERFLOW ", + "keep nfull ss-full | false | PrioritySampling.SAMPLER_KEEP | 1 | 0 | true | ENQUEUED_FOR_SERIALIZATION ", + "drop nfull 2 ss-full | false | PrioritySampling.SAMPLER_DROP | 0 | 1 | true | DROPPED_BUFFER_OVERFLOW ", + "ukeep nfull ss-full | false | PrioritySampling.USER_KEEP | 1 | 0 | true | ENQUEUED_FOR_SERIALIZATION " + }) + void testEnsureTraceStrategyWithSpanSamplingQueue( + boolean primaryFull, + @ConvertWith(PrioritySamplingConverter.class) int priority, + int primaryOffers, + int singleSpanOffers, + boolean singleSpanFull, + PublishResult expectedResult) { + List trace = Collections.emptyList(); + Queue primary = mock(Queue.class); + Queue secondary = mock(Queue.class); + Queue spanSampling = mock(Queue.class); + PrioritizationStrategy blocking = + Prioritization.ENSURE_TRACE.create(primary, secondary, spanSampling, () -> false); + when(primary.offer(trace)).thenReturn(!primaryFull, true); + when(spanSampling.offer(trace)).thenReturn(!singleSpanFull); + + PublishResult publishResult = blocking.publish(mock(DDSpan.class), priority, trace); + + assertEquals(expectedResult, publishResult); + verify(primary, times(primaryOffers)).offer(trace); + verify(secondary, never()).offer(trace); // expect no traces sent to the secondary queue + verify(spanSampling, times(singleSpanOffers)).offer(trace); + } + + @SuppressWarnings("unchecked") + @TableTest({ + "scenario | priority | primaryOffers | singleSpanOffers | singleSpanFull | expectedResult ", + "unset ss-not-full | PrioritySampling.UNSET | 1 | 0 | false | ENQUEUED_FOR_SERIALIZATION ", + "drop ss-not-full | PrioritySampling.SAMPLER_DROP | 0 | 1 | false | ENQUEUED_FOR_SINGLE_SPAN_SAMPLING", + "keep ss-not-full | PrioritySampling.SAMPLER_KEEP | 1 | 0 | false | ENQUEUED_FOR_SERIALIZATION ", + "drop 2 ss-not-full | PrioritySampling.SAMPLER_DROP | 0 | 1 | false | ENQUEUED_FOR_SINGLE_SPAN_SAMPLING", + "user keep ss-not-full | PrioritySampling.USER_KEEP | 1 | 0 | false | ENQUEUED_FOR_SERIALIZATION ", + "unset ss-full | PrioritySampling.UNSET | 1 | 0 | true | ENQUEUED_FOR_SERIALIZATION ", + "drop ss-full | PrioritySampling.SAMPLER_DROP | 0 | 1 | true | DROPPED_BUFFER_OVERFLOW ", + "keep ss-full | PrioritySampling.SAMPLER_KEEP | 1 | 0 | true | ENQUEUED_FOR_SERIALIZATION ", + "drop 2 ss-full | PrioritySampling.SAMPLER_DROP | 0 | 1 | true | DROPPED_BUFFER_OVERFLOW ", + "user keep ss-full | PrioritySampling.USER_KEEP | 1 | 0 | true | ENQUEUED_FOR_SERIALIZATION " + }) + void testFastLaneStrategyWithSpanSamplingQueue( + @ConvertWith(PrioritySamplingConverter.class) int priority, + int primaryOffers, + int singleSpanOffers, + boolean singleSpanFull, + PublishResult expectedResult) { + List trace = Collections.emptyList(); + Queue primary = mock(Queue.class); + Queue secondary = mock(Queue.class); + Queue spanSampling = mock(Queue.class); + PrioritizationStrategy fastLane = + Prioritization.FAST_LANE.create(primary, secondary, spanSampling, () -> false); + when(primary.offer(trace)).thenReturn(true); + when(spanSampling.offer(trace)).thenReturn(!singleSpanFull); + + PublishResult publishResult = fastLane.publish(mock(DDSpan.class), priority, trace); + + assertEquals(expectedResult, publishResult); + verify(primary, times(primaryOffers)).offer(trace); + verify(secondary, never()).offer(any()); // expect no traces sent to the secondary queue + verify(spanSampling, times(singleSpanOffers)).offer(trace); + } + + @SuppressWarnings("unchecked") + @TableTest({ + "scenario | priority | primaryOffers | singleSpanOffers | expectedResult ", + "unset | PrioritySampling.UNSET | 1 | 0 | ENQUEUED_FOR_SERIALIZATION ", + "drop | PrioritySampling.SAMPLER_DROP | 0 | 1 | ENQUEUED_FOR_SINGLE_SPAN_SAMPLING", + "keep | PrioritySampling.SAMPLER_KEEP | 1 | 0 | ENQUEUED_FOR_SERIALIZATION ", + "drop 2 | PrioritySampling.SAMPLER_DROP | 0 | 1 | ENQUEUED_FOR_SINGLE_SPAN_SAMPLING", + "user keep | PrioritySampling.USER_KEEP | 1 | 0 | ENQUEUED_FOR_SERIALIZATION " + }) + void testFastLaneWithActiveDroppingPolicySendToSingleSpanSampling( + @ConvertWith(PrioritySamplingConverter.class) int priority, + int primaryOffers, + int singleSpanOffers, + PublishResult expectedResult) { + List trace = Collections.emptyList(); + Queue primary = mock(Queue.class); + Queue secondary = mock(Queue.class); + Queue spanSampling = mock(Queue.class); + PrioritizationStrategy drop = + Prioritization.FAST_LANE.create(primary, secondary, spanSampling, () -> true); + when(primary.offer(trace)).thenReturn(true); + when(spanSampling.offer(trace)).thenReturn(true); + + PublishResult publishResult = drop.publish(mock(DDSpan.class), priority, trace); + + assertEquals(expectedResult, publishResult); + verify(primary, times(primaryOffers)).offer(trace); + verify(secondary, never()).offer(trace); + verify(spanSampling, times(singleSpanOffers)).offer(trace); + } + + @SuppressWarnings("unchecked") + @TableTest({ + "scenario | strategy | forceKeep | singleSpanFull | expectedResult ", + "force keep true full | FAST_LANE | true | true | ENQUEUED_FOR_SERIALIZATION ", + "force keep false full | FAST_LANE | false | true | DROPPED_BUFFER_OVERFLOW ", + "force keep true not full | FAST_LANE | true | false | ENQUEUED_FOR_SERIALIZATION ", + "force keep false not full | FAST_LANE | false | false | ENQUEUED_FOR_SINGLE_SPAN_SAMPLING" + }) + void testSpanSamplingDropStrategyRespectsForceKeep( + Prioritization strategy, + boolean forceKeep, + boolean singleSpanFull, + PublishResult expectedResult) { + Queue primary = mock(Queue.class); + Queue spanSampling = mock(Queue.class); + PrioritizationStrategy drop = strategy.create(primary, null, spanSampling, () -> true); + DDSpan root = mock(DDSpan.class); + List trace = Collections.singletonList(root); + when(root.isForceKeep()).thenReturn(forceKeep); + when(primary.offer(trace)).thenReturn(true); + when(spanSampling.offer(trace)).thenReturn(!singleSpanFull); + + PublishResult publishResult = drop.publish(root, PrioritySampling.SAMPLER_DROP, trace); + + assertEquals(expectedResult, publishResult); + verify(root).isForceKeep(); + verify(primary, times(forceKeep ? 1 : 0)).offer(trace); + verify(spanSampling, times(forceKeep ? 0 : 1)).offer(trace); + verifyNoMoreInteractions(root, primary, spanSampling); + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/common/writer/SerializationTest.java b/dd-trace-core/src/test/java/datadog/trace/common/writer/SerializationTest.java new file mode 100644 index 00000000000..0924581f26b --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/common/writer/SerializationTest.java @@ -0,0 +1,60 @@ +package datadog.trace.common.writer; + +import static java.util.Collections.singletonMap; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import datadog.trace.test.util.DDJavaSpecification; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.msgpack.core.MessageBufferPacker; +import org.msgpack.core.MessagePack; +import org.msgpack.jackson.dataformat.MessagePackFactory; + +class SerializationTest extends DDJavaSpecification { + + @Test + void testJsonMapperSerialization() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + Map map = singletonMap("key1", "val1"); + byte[] serializedMap = mapper.writeValueAsBytes(map); + byte[] serializedList = ("[" + new String(serializedMap) + "]").getBytes(); + + List> result = + mapper.readValue(serializedList, new TypeReference>>() {}); + + assertEquals(Collections.singletonList(map), result); + assertEquals("[{\"key1\":\"val1\"}]", new String(serializedList)); + } + + @Test + void testMsgpackMapperSerialization() throws Exception { + // setup + ObjectMapper mapper = new ObjectMapper(new MessagePackFactory()); + // GStrings get odd results in the serializer. + List> input = new ArrayList<>(); + for (int i = 1; i <= 1; i++) { + input.add(singletonMap("key" + i, "val" + i)); + } + List serializedMaps = new ArrayList<>(); + for (Map item : input) { + serializedMaps.add(mapper.writeValueAsBytes(item)); + } + + MessageBufferPacker packer = MessagePack.newDefaultBufferPacker(); + packer.packArrayHeader(serializedMaps.size()); + for (byte[] bytes : serializedMaps) { + packer.writePayload(bytes); + } + byte[] serializedList = packer.toByteArray(); + + List> result = + mapper.readValue(serializedList, new TypeReference>>() {}); + + assertEquals(input, result); + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/common/writer/SpanSamplingWorkerTest.java b/dd-trace-core/src/test/java/datadog/trace/common/writer/SpanSamplingWorkerTest.java new file mode 100644 index 00000000000..f03ecc985df --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/common/writer/SpanSamplingWorkerTest.java @@ -0,0 +1,373 @@ +package datadog.trace.common.writer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import datadog.trace.api.sampling.PrioritySampling; +import datadog.trace.common.sampling.SingleSpanSampler; +import datadog.trace.core.DDSpan; +import datadog.trace.core.monitor.HealthMetrics; +import datadog.trace.test.util.DDJavaSpecification; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.tabletest.junit.TableTest; + +class SpanSamplingWorkerTest extends DDJavaSpecification { + + @Test + void testSendOnlySampledSpansToTheSampledSpanQueue() throws InterruptedException { + BlockingQueue primaryQueue = new LinkedBlockingDeque<>(10); + BlockingQueue secondaryQueue = new LinkedBlockingDeque<>(10); + SingleSpanSampler singleSpanSampler = mock(SingleSpanSampler.class); + HealthMetrics healthMetrics = mock(HealthMetrics.class); + SpanSamplingWorker worker = + SpanSamplingWorker.build( + 10, primaryQueue, secondaryQueue, singleSpanSampler, healthMetrics, () -> false); + worker.start(); + DDSpan span1 = mock(DDSpan.class); + DDSpan span2 = mock(DDSpan.class); + DDSpan span3 = mock(DDSpan.class); + when(singleSpanSampler.setSamplingPriority(span1)).thenReturn(true); + when(singleSpanSampler.setSamplingPriority(span2)).thenReturn(false); + when(singleSpanSampler.setSamplingPriority(span3)).thenReturn(true); + + worker.getSpanSamplingQueue().offer(Arrays.asList(span1, span2, span3)); + + assertEquals(Arrays.asList(span1, span3), primaryQueue.take()); + assertEquals(Arrays.asList(span2), secondaryQueue.take()); + + worker.close(); + } + + @Test + void testHandleMultipleTraces() throws InterruptedException { + BlockingQueue primaryQueue = new LinkedBlockingDeque<>(10); + BlockingQueue secondaryQueue = new LinkedBlockingDeque<>(10); + SingleSpanSampler singleSpanSampler = mock(SingleSpanSampler.class); + HealthMetrics healthMetrics = mock(HealthMetrics.class); + SpanSamplingWorker worker = + SpanSamplingWorker.build( + 10, primaryQueue, secondaryQueue, singleSpanSampler, healthMetrics, () -> false); + worker.start(); + DDSpan span1 = mock(DDSpan.class); + DDSpan span2 = mock(DDSpan.class); + DDSpan span3 = mock(DDSpan.class); + when(singleSpanSampler.setSamplingPriority(span1)).thenReturn(true); + when(singleSpanSampler.setSamplingPriority(span2)).thenReturn(false); + when(singleSpanSampler.setSamplingPriority(span3)).thenReturn(true); + DDSpan span4 = mock(DDSpan.class); + DDSpan span5 = mock(DDSpan.class); + when(singleSpanSampler.setSamplingPriority(span4)).thenReturn(true); + when(singleSpanSampler.setSamplingPriority(span5)).thenReturn(false); + + worker.getSpanSamplingQueue().offer(Arrays.asList(span1, span2, span3)); + worker.getSpanSamplingQueue().offer(Arrays.asList(span4, span5)); + + assertEquals(Arrays.asList(span1, span3), primaryQueue.take()); + assertEquals(Arrays.asList(span2), secondaryQueue.take()); + assertEquals(Arrays.asList(span4), primaryQueue.take()); + assertEquals(Arrays.asList(span5), secondaryQueue.take()); + + worker.close(); + } + + @Test + void testSkipTracesWithNoSampledSpans() throws InterruptedException { + BlockingQueue primaryQueue = new LinkedBlockingDeque<>(10); + BlockingQueue secondaryQueue = new LinkedBlockingDeque<>(10); + SingleSpanSampler singleSpanSampler = mock(SingleSpanSampler.class); + HealthMetrics healthMetrics = mock(HealthMetrics.class); + SpanSamplingWorker worker = + SpanSamplingWorker.build( + 10, primaryQueue, secondaryQueue, singleSpanSampler, healthMetrics, () -> false); + worker.start(); + DDSpan span1 = mock(DDSpan.class); + DDSpan span2 = mock(DDSpan.class); + DDSpan span3 = mock(DDSpan.class); + when(singleSpanSampler.setSamplingPriority(span1)).thenReturn(true); + when(singleSpanSampler.setSamplingPriority(span2)).thenReturn(false); + when(singleSpanSampler.setSamplingPriority(span3)).thenReturn(true); + DDSpan span4 = mock(DDSpan.class); + DDSpan span5 = mock(DDSpan.class); + when(singleSpanSampler.setSamplingPriority(span4)).thenReturn(false); + when(singleSpanSampler.setSamplingPriority(span5)).thenReturn(false); + DDSpan span6 = mock(DDSpan.class); + DDSpan span7 = mock(DDSpan.class); + when(singleSpanSampler.setSamplingPriority(span6)).thenReturn(true); + when(singleSpanSampler.setSamplingPriority(span7)).thenReturn(true); + + assertTrue(worker.getSpanSamplingQueue().offer(Arrays.asList(span1, span2, span3))); + assertTrue(worker.getSpanSamplingQueue().offer(Arrays.asList(span4, span5))); + assertTrue(worker.getSpanSamplingQueue().offer(Arrays.asList(span6, span7))); + + assertEquals(Arrays.asList(span1, span3), primaryQueue.take()); + assertEquals(Arrays.asList(span2), secondaryQueue.take()); + assertEquals(Arrays.asList(span4, span5), secondaryQueue.take()); + assertEquals(Arrays.asList(span6, span7), primaryQueue.take()); + + worker.close(); + } + + @Test + void testIgnoreEmptyTraces() throws InterruptedException { + BlockingQueue primaryQueue = new LinkedBlockingDeque<>(10); + BlockingQueue secondaryQueue = new LinkedBlockingDeque<>(10); + SingleSpanSampler singleSpanSampler = mock(SingleSpanSampler.class); + HealthMetrics healthMetrics = mock(HealthMetrics.class); + SpanSamplingWorker worker = + SpanSamplingWorker.build( + 10, primaryQueue, secondaryQueue, singleSpanSampler, healthMetrics, () -> false); + worker.start(); + DDSpan span1 = mock(DDSpan.class); + when(singleSpanSampler.setSamplingPriority(span1)).thenReturn(true); + + assertTrue(worker.getSpanSamplingQueue().offer(java.util.Collections.emptyList())); + assertTrue(worker.getSpanSamplingQueue().offer(Arrays.asList(span1))); + + assertEquals(Arrays.asList(span1), primaryQueue.take()); + assertTrue(secondaryQueue.isEmpty()); + + worker.close(); + } + + @Test + void testUpdateDroppedTracesMetricWhenNoTracerSpansHaveBeenSampled() throws InterruptedException { + BlockingQueue primaryQueue = new LinkedBlockingDeque<>(10); + BlockingQueue secondaryQueue = new LinkedBlockingDeque<>(10); + SingleSpanSampler singleSpanSampler = mock(SingleSpanSampler.class); + HealthMetrics healthMetrics = mock(HealthMetrics.class); + CountDownLatch latch = new CountDownLatch(1); + SpanSamplingWorker worker = + new SpanSamplingWorker.DefaultSpanSamplingWorker( + 10, primaryQueue, secondaryQueue, singleSpanSampler, healthMetrics, () -> false) { + @Override + protected void afterOnEvent() { + latch.countDown(); + } + }; + worker.start(); + DDSpan span1 = mock(DDSpan.class); + DDSpan span2 = mock(DDSpan.class); + when(span1.samplingPriority()).thenReturn((int) PrioritySampling.USER_DROP); + when(singleSpanSampler.setSamplingPriority(span1)).thenReturn(false); + when(singleSpanSampler.setSamplingPriority(span2)).thenReturn(false); + List trace = Arrays.asList(span1, span2); + + assertTrue(worker.getSpanSamplingQueue().offer(trace)); + + // wait for processing + assertTrue(latch.await(10, TimeUnit.SECONDS)); + assertTrue(primaryQueue.isEmpty()); + assertEquals(trace, secondaryQueue.take()); + verify(healthMetrics).onPublish(trace, PrioritySampling.USER_DROP); + verify(healthMetrics, never()).onFailedPublish(anyInt(), anyInt()); + verify(healthMetrics, never()).onPartialPublish(anyInt()); + + worker.close(); + } + + @Test + void testUpdateDroppedTracesMetricWhenPrimaryQueueIsFull() throws InterruptedException { + BlockingQueue primaryQueue = new LinkedBlockingDeque<>(1); + BlockingQueue secondaryQueue = new LinkedBlockingDeque<>(10); + primaryQueue.offer(java.util.Collections.emptyList()); // occupy the entire queue + SingleSpanSampler singleSpanSampler = mock(SingleSpanSampler.class); + HealthMetrics healthMetrics = mock(HealthMetrics.class); + CountDownLatch latch = new CountDownLatch(1); + SpanSamplingWorker worker = + new SpanSamplingWorker.DefaultSpanSamplingWorker( + 10, primaryQueue, secondaryQueue, singleSpanSampler, healthMetrics, () -> false) { + @Override + protected void afterOnEvent() { + latch.countDown(); + } + }; + worker.start(); + DDSpan span1 = mock(DDSpan.class); + DDSpan span2 = mock(DDSpan.class); + when(span1.samplingPriority()).thenReturn((int) PrioritySampling.SAMPLER_DROP); + when(singleSpanSampler.setSamplingPriority(span1)).thenReturn(false); + when(singleSpanSampler.setSamplingPriority(span2)).thenReturn(true); + List trace = Arrays.asList(span1, span2); + + assertTrue(worker.getSpanSamplingQueue().offer(trace)); + + // wait for processing + assertTrue(latch.await(10, TimeUnit.SECONDS)); + assertTrue(secondaryQueue.isEmpty()); + verify(healthMetrics).onFailedPublish(eq((int) PrioritySampling.SAMPLER_DROP), anyInt()); + verify(healthMetrics, never()).onPublish(any(), anyInt()); + verify(healthMetrics, never()).onPartialPublish(anyInt()); + + worker.close(); + } + + @Test + void testUpdatePublishedTracesMetricWhenAllTraceSpansHaveBeenSampled() + throws InterruptedException { + BlockingQueue primaryQueue = new LinkedBlockingDeque<>(10); + BlockingQueue secondaryQueue = new LinkedBlockingDeque<>(10); + SingleSpanSampler singleSpanSampler = mock(SingleSpanSampler.class); + HealthMetrics healthMetrics = mock(HealthMetrics.class); + SpanSamplingWorker worker = + SpanSamplingWorker.build( + 10, primaryQueue, secondaryQueue, singleSpanSampler, healthMetrics, () -> false); + worker.start(); + DDSpan span1 = mock(DDSpan.class); + DDSpan span2 = mock(DDSpan.class); + when(span1.samplingPriority()).thenReturn((int) PrioritySampling.SAMPLER_DROP); + when(singleSpanSampler.setSamplingPriority(span1)).thenReturn(true); + when(singleSpanSampler.setSamplingPriority(span2)).thenReturn(true); + List trace = Arrays.asList(span1, span2); + + assertTrue(worker.getSpanSamplingQueue().offer(trace)); + + // take() blocks until worker has put spans in primaryQueue + assertEquals(trace, primaryQueue.take()); + assertTrue(secondaryQueue.isEmpty()); + // use timeout to wait for healthMetrics to be called after queue operations + verify(healthMetrics, timeout(5000)).onPublish(trace, PrioritySampling.SAMPLER_DROP); + verify(healthMetrics, never()).onFailedPublish(anyInt(), anyInt()); + verify(healthMetrics, never()).onPartialPublish(anyInt()); + + worker.close(); + } + + @Test + void testUpdatePartialTracesMetricWhenSomeSpansDroppedAndSentToSecondaryQueue() + throws InterruptedException { + BlockingQueue primaryQueue = new LinkedBlockingDeque<>(10); + BlockingQueue secondaryQueue = new LinkedBlockingDeque<>(10); + SingleSpanSampler singleSpanSampler = mock(SingleSpanSampler.class); + HealthMetrics healthMetrics = mock(HealthMetrics.class); + SpanSamplingWorker worker = + SpanSamplingWorker.build( + 10, primaryQueue, secondaryQueue, singleSpanSampler, healthMetrics, () -> false); + worker.start(); + DDSpan span1 = mock(DDSpan.class); + DDSpan span2 = mock(DDSpan.class); + DDSpan span3 = mock(DDSpan.class); + when(singleSpanSampler.setSamplingPriority(span1)).thenReturn(false); + when(singleSpanSampler.setSamplingPriority(span2)).thenReturn(true); + when(singleSpanSampler.setSamplingPriority(span3)).thenReturn(false); + List trace = Arrays.asList(span1, span2, span3); + + assertTrue(worker.getSpanSamplingQueue().offer(trace)); + + // take() blocks until worker has put spans in queues + assertEquals(Arrays.asList(span2), primaryQueue.take()); + assertEquals(Arrays.asList(span1, span3), secondaryQueue.take()); + // use timeout to wait for healthMetrics to be called after queue operations + verify(healthMetrics, timeout(5000)).onPublish(trace, PrioritySampling.SAMPLER_DROP); + verify(healthMetrics, never()).onPartialPublish(anyInt()); + verify(healthMetrics, never()).onFailedPublish(anyInt(), anyInt()); + + worker.close(); + } + + @TableTest({ + "scenario | droppingPolicy | secondaryQueueIsFull", + "dropping active | true | false ", + "secondary queue full | false | true ", + "dropping active+full queue | true | true " + }) + void testUpdatePartialTracesMetricWhenSpansDroppedAndSecondaryQueueFullOrDroppingPolicyActive( + boolean droppingPolicy, boolean secondaryQueueIsFull) throws InterruptedException { + BlockingQueue primaryQueue = new LinkedBlockingDeque<>(10); + BlockingQueue secondaryQueue = new LinkedBlockingDeque<>(secondaryQueueIsFull ? 1 : 10); + if (secondaryQueueIsFull) { + // occupy the entire queue + secondaryQueue.offer(java.util.Collections.emptyList()); + } + SingleSpanSampler singleSpanSampler = mock(SingleSpanSampler.class); + HealthMetrics healthMetrics = mock(HealthMetrics.class); + SpanSamplingWorker worker = + new SpanSamplingWorker.DefaultSpanSamplingWorker( + 10, + primaryQueue, + secondaryQueue, + singleSpanSampler, + healthMetrics, + () -> droppingPolicy); + worker.start(); + DDSpan span1 = mock(DDSpan.class); + DDSpan span2 = mock(DDSpan.class); + DDSpan span3 = mock(DDSpan.class); + when(span1.samplingPriority()).thenReturn((int) PrioritySampling.SAMPLER_DROP); + when(singleSpanSampler.setSamplingPriority(span1)).thenReturn(false); + when(singleSpanSampler.setSamplingPriority(span2)).thenReturn(true); + when(singleSpanSampler.setSamplingPriority(span3)).thenReturn(false); + + assertTrue(worker.getSpanSamplingQueue().offer(Arrays.asList(span1, span2, span3))); + + // take() blocks until worker has put span2 in primaryQueue + assertEquals(Arrays.asList(span2), primaryQueue.take()); + // use timeout to wait for healthMetrics to be called after queue operations + verify(healthMetrics, timeout(5000)).onPartialPublish(2); + verify(healthMetrics, never()).onFailedPublish(anyInt(), anyInt()); + verify(healthMetrics, never()).onPublish(any(), anyInt()); + + worker.close(); + } + + @TableTest({ + "scenario | droppingPolicy | secondaryQueueIsFull", + "dropping active | true | false ", + "secondary queue full | false | true ", + "dropping active+full queue | true | true " + }) + void testUpdateFailedPublishMetricWhenAllSpansDroppedAndSecondaryQueueFullOrDroppingPolicyActive( + boolean droppingPolicy, boolean secondaryQueueIsFull) throws InterruptedException { + BlockingQueue primaryQueue = new LinkedBlockingDeque<>(10); + BlockingQueue secondaryQueue = new LinkedBlockingDeque<>(secondaryQueueIsFull ? 1 : 10); + if (secondaryQueueIsFull) { + // occupy the entire queue + secondaryQueue.offer(java.util.Collections.emptyList()); + } + SingleSpanSampler singleSpanSampler = mock(SingleSpanSampler.class); + HealthMetrics healthMetrics = mock(HealthMetrics.class); + CountDownLatch latch = new CountDownLatch(1); + SpanSamplingWorker worker = + new SpanSamplingWorker.DefaultSpanSamplingWorker( + 10, + primaryQueue, + secondaryQueue, + singleSpanSampler, + healthMetrics, + () -> droppingPolicy) { + @Override + protected void afterOnEvent() { + latch.countDown(); + } + }; + worker.start(); + DDSpan span1 = mock(DDSpan.class); + DDSpan span2 = mock(DDSpan.class); + when(span1.samplingPriority()).thenReturn((int) PrioritySampling.SAMPLER_DROP); + when(singleSpanSampler.setSamplingPriority(span1)).thenReturn(false); + when(singleSpanSampler.setSamplingPriority(span2)).thenReturn(false); + + assertTrue(worker.getSpanSamplingQueue().offer(Arrays.asList(span1, span2))); + + // wait for processing via latch + assertTrue(latch.await(10, TimeUnit.SECONDS)); + verify(healthMetrics).onFailedPublish(eq((int) PrioritySampling.SAMPLER_DROP), anyInt()); + verify(healthMetrics, never()).onPartialPublish(anyInt()); + verify(healthMetrics, never()).onPublish(any(), anyInt()); + + worker.close(); + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/common/writer/TraceMapperTest.java b/dd-trace-core/src/test/java/datadog/trace/common/writer/TraceMapperTest.java new file mode 100644 index 00000000000..a88d7d9000f --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/common/writer/TraceMapperTest.java @@ -0,0 +1,126 @@ +package datadog.trace.common.writer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.communication.serialization.ByteBufferConsumer; +import datadog.communication.serialization.FlushingBuffer; +import datadog.communication.serialization.GrowableBuffer; +import datadog.communication.serialization.msgpack.MsgPackWriter; +import datadog.trace.common.writer.ddagent.TraceMapperTestBridge; +import datadog.trace.common.writer.ddagent.TraceMapperV0_5; +import datadog.trace.core.CoreTracer; +import datadog.trace.core.DDCoreJavaSpecification; +import datadog.trace.core.DDSpan; +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.msgpack.core.MessagePack; +import org.msgpack.core.MessageUnpacker; + +class TraceMapperTest extends DDCoreJavaSpecification { + + @Test + void testTraceMapperV05() throws Exception { + CoreTracer tracer = tracerBuilder().writer(new ListWriter()).build(); + DDSpan span = + (DDSpan) + tracer + .buildSpan("datadog", null) + .withTag("service.name", "my-service") + .withTag("elasticsearch.version", "7.0") + .start(); + span.setBaggageItem("baggage", "item"); + span.spanContext().setDataTop("mydata", "[1,2,3]"); + List trace = Collections.singletonList(span); + + TraceMapperV0_5 traceMapper = new TraceMapperV0_5(); + CapturingByteBufferConsumer sink = new CapturingByteBufferConsumer(); + MsgPackWriter packer = new MsgPackWriter(new FlushingBuffer(1024, sink)); + packer.format(trace, traceMapper); + packer.flush(); + + // only top-level statements in Spock then-blocks are power assertions; + // expressions inside for-loops are not, so we only assert the critical outcomes + assertNotNull(sink.captured); + GrowableBuffer dictionaryBuffer = TraceMapperTestBridge.getDictionary(traceMapper); + ByteBuffer dictionaryBytes = dictionaryBuffer.slice(); + Map meta = new HashMap<>(); + + try (MessageUnpacker dictionaryUnpacker = MessagePack.newDefaultUnpacker(dictionaryBytes)) { + int dictionaryLength = TraceMapperTestBridge.getEncoding(traceMapper).size(); + String[] dictionary = new String[dictionaryLength]; + for (int i = 0; i < dictionary.length; ++i) { + dictionary[i] = dictionaryUnpacker.unpackString(); + } + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(sink.captured)) { + int traceCount = unpacker.unpackArrayHeader(); + assertEquals(1, traceCount); + for (int i = 0; i < traceCount; ++i) { + int arrayLength = unpacker.unpackArrayHeader(); + assertEquals(12, arrayLength); + String serviceName = dictionary[unpacker.unpackInt()]; + assertEquals("my-service", serviceName); + String operationName = dictionary[unpacker.unpackInt()]; + assertTrue(operationName.isEmpty()); + String resourceName = dictionary[unpacker.unpackInt()]; + assertTrue(resourceName.isEmpty()); + long traceId = unpacker.unpackLong(); + assertTrue(traceId > 0); + long spanId = unpacker.unpackLong(); + assertTrue(spanId > 0); + long parentId = unpacker.unpackLong(); + assertEquals(0, parentId); + long start = unpacker.unpackLong(); + assertTrue(start > 0); + long duration = unpacker.unpackLong(); + assertEquals(-start, duration); + int error = unpacker.unpackInt(); + assertEquals(0, error); + int metaHeader = unpacker.unpackMapHeader(); + for (int j = 0; j < metaHeader; ++j) { + String key = dictionary[unpacker.unpackInt()]; + assertNotNull(key); + String value = dictionary[unpacker.unpackInt()]; + assertNotNull(value); + meta.put(key, value); + } + int metricsHeader = unpacker.unpackMapHeader(); + for (int j = 0; j < metricsHeader; ++j) { + String key = dictionary[unpacker.unpackInt()]; + assertNotNull(key); + unpacker.skipValue(); + } + String type = dictionary[unpacker.unpackInt()]; + assertNotNull(type); + // find the meta entry whose key contains ".mydata." and verify its value + String myDataValue = null; + for (Map.Entry entry : meta.entrySet()) { + if (entry.getKey().contains(".mydata.")) { + myDataValue = entry.getValue(); + break; + } + } + + assertEquals("[1,2,3]", myDataValue); + } + } + } + + tracer.close(); + } + + static class CapturingByteBufferConsumer implements ByteBufferConsumer { + + ByteBuffer captured; + + @Override + public void accept(int messageCount, ByteBuffer buffer) { + captured = buffer; + } + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/common/writer/TraceProcessingWorkerTest.java b/dd-trace-core/src/test/java/datadog/trace/common/writer/TraceProcessingWorkerTest.java new file mode 100644 index 00000000000..86dbfa61b22 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/common/writer/TraceProcessingWorkerTest.java @@ -0,0 +1,615 @@ +package datadog.trace.common.writer; + +import static datadog.trace.api.sampling.PrioritySampling.SAMPLER_KEEP; +import static datadog.trace.common.writer.ddagent.Prioritization.FAST_LANE; +import static datadog.trace.common.writer.ddagent.PrioritizationStrategy.PublishResult.ENQUEUED_FOR_SERIALIZATION; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import datadog.trace.bootstrap.instrumentation.api.SpanPostProcessor; +import datadog.trace.common.sampling.SingleSpanSampler; +import datadog.trace.common.writer.ddagent.PrioritizationStrategy.PublishResult; +import datadog.trace.core.CoreSpan; +import datadog.trace.core.DDSpan; +import datadog.trace.core.DDSpanContext; +import datadog.trace.core.PendingTrace; +import datadog.trace.core.monitor.HealthMetrics; +import datadog.trace.test.junit.utils.converter.PrioritySamplingConverter; +import datadog.trace.test.util.DDJavaSpecification; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.converter.ConvertWith; +import org.tabletest.junit.TableTest; + +class TraceProcessingWorkerTest extends DDJavaSpecification { + + // ------------------------------------------------------------------------- + // Helper: flush-counting payload dispatcher + // ------------------------------------------------------------------------- + + private PayloadDispatcherImpl flushCountingPayloadDispatcher(AtomicInteger flushCounter) { + PayloadDispatcherImpl dispatcher = mock(PayloadDispatcherImpl.class); + doAnswer( + inv -> { + flushCounter.incrementAndGet(); + return null; + }) + .when(dispatcher) + .flush(); + return dispatcher; + } + + // ------------------------------------------------------------------------- + // Test 1: heartbeats should be triggered automatically when enabled + // ------------------------------------------------------------------------- + + @Test + void testHeartbeatsShouldBeTriggeredAutomaticallyWhenEnabled() throws Exception { + AtomicInteger flushCount = new AtomicInteger(); + try (TraceProcessingWorker worker = + new TraceProcessingWorker( + 10, + mock(HealthMetrics.class), + flushCountingPayloadDispatcher(flushCount), + () -> false, + FAST_LANE, + 1, + TimeUnit.NANOSECONDS, // stop heartbeats from being throttled + null)) { + + // processor is started + worker.start(); + + // heartbeat occurs automatically + long deadline = System.currentTimeMillis() + 5000; + while (System.currentTimeMillis() < deadline) { + if (flushCount.get() > 0) break; + Thread.sleep(50); + } + assertTrue(flushCount.get() > 0); + } + } + + // ------------------------------------------------------------------------- + // Test 2: heartbeats should occur at least once per second when not throttled + // ------------------------------------------------------------------------- + + @Test + void testHeartbeatsShouldOccurAtLeastOncePerSecondWhenNotThrottled() throws Exception { + AtomicInteger flushCount = new AtomicInteger(); + try (TraceProcessingWorker worker = + new TraceProcessingWorker( + 10, + mock(HealthMetrics.class), + flushCountingPayloadDispatcher(flushCount), + () -> false, + FAST_LANE, + 1, + TimeUnit.NANOSECONDS, // stop heartbeats from being throttled + null)) { + + // processor is started + worker.start(); + + // heartbeat occurs automatically approximately once per second + // wait 1 second initial delay, then poll up to 1 second + Thread.sleep(1000); + long deadline = System.currentTimeMillis() + 1000; + while (System.currentTimeMillis() < deadline) { + if (flushCount.get() > 1) break; + Thread.sleep(50); + } + assertTrue(flushCount.get() > 1); + } + } + + // ------------------------------------------------------------------------- + // Test 3: a flush should clear the primary queue + // ------------------------------------------------------------------------- + + @Test + void testAFlushShouldClearThePrimaryQueue() { + AtomicInteger flushCount = new AtomicInteger(); + // prevent heartbeats from helping the flush happen + + try (TraceProcessingWorker worker = + new TraceProcessingWorker( + 10, + mock(HealthMetrics.class), + flushCountingPayloadDispatcher(flushCount), + () -> false, + FAST_LANE, + 100, + TimeUnit.SECONDS, // prevent heartbeats from helping the flush happen + null)) { + // there is pending work it is completed before a flush + // processing this span will throw an exception, but it should be caught + // and not disrupt the flush + List trace = Collections.singletonList(mock(DDSpan.class)); + worker.getPrimaryQueue().offer(trace); + worker.start(); + boolean flushed = worker.flush(10, TimeUnit.SECONDS); + + // the flush succeeds, triggers a dispatch, and the queue is empty + assertTrue(flushed); + assertEquals(1, flushCount.get()); + assertTrue(worker.getPrimaryQueue().isEmpty()); + } + } + + // ------------------------------------------------------------------------- + // Test 4: should report failure if serialization fails + // ------------------------------------------------------------------------- + + @TableTest({ + "scenario | priority ", + "sampler drop | PrioritySampling.SAMPLER_DROP", + "user drop | PrioritySampling.USER_DROP ", + "sampler keep | PrioritySampling.SAMPLER_KEEP", + "user keep | PrioritySampling.USER_KEEP ", + "unset | PrioritySampling.UNSET " + }) + void testShouldReportFailureIfSerializationFails( + @ConvertWith(PrioritySamplingConverter.class) int priority) throws Exception { + Throwable theError = new IllegalStateException("thrown by test"); + PayloadDispatcherImpl throwingDispatcher = mock(PayloadDispatcherImpl.class); + doAnswer( + inv -> { + throw theError; + }) + .when(throwingDispatcher) + .addTrace(any()); + + AtomicInteger errorReported = new AtomicInteger(); + HealthMetrics healthMetrics = mock(HealthMetrics.class); + // do this manually with a counter, despite mockito's lovely syntactical sugar so we don't have + // a race condition induced flaky test. All we care about is that an error was reported and that + // it was the right one + doAnswer( + inv -> { + errorReported.incrementAndGet(); + return null; + }) + .when(healthMetrics) + .onFailedSerialize(any(), any()); + + // prevent heartbeats from helping the flush happen + + try (TraceProcessingWorker worker = + new TraceProcessingWorker( + 10, + healthMetrics, + throwingDispatcher, + () -> false, + FAST_LANE, + 100, + TimeUnit.SECONDS, // prevent heartbeats from helping the flush happen + null)) { + worker.start(); + // a trace is processed but can't be passed on + worker.publish(mock(DDSpan.class), priority, Collections.singletonList(mock(DDSpan.class))); + + // the error is reported to the monitor + long deadline = System.currentTimeMillis() + 5000; + while (System.currentTimeMillis() < deadline) { + if (errorReported.get() == 1) break; + Thread.sleep(50); + } + assertEquals(1, errorReported.get()); + } + } + + // ------------------------------------------------------------------------- + // Test 5: trace should be post-processed + // ------------------------------------------------------------------------- + + @Test + void testTraceShouldBePostProcessed() throws Exception { + AtomicInteger acceptedCount = new AtomicInteger(); + PayloadDispatcherImpl countingDispatcher = mock(PayloadDispatcherImpl.class); + doAnswer( + inv -> { + acceptedCount.getAndIncrement(); + return null; + }) + .when(countingDispatcher) + .addTrace(any()); + HealthMetrics healthMetrics = mock(HealthMetrics.class); + + // Create real DDSpan instances via reflection (DDSpan.create is package-private) + Method createMethod = + DDSpan.class.getDeclaredMethod( + "create", String.class, long.class, DDSpanContext.class, List.class); + createMethod.setAccessible(true); + + DDSpanContext ctx1 = mock(DDSpanContext.class); + PendingTrace pendingTrace1 = mock(PendingTrace.class); + when(ctx1.getTraceCollector()).thenReturn(pendingTrace1); + when(pendingTrace1.getCurrentTimeNano()).thenReturn(0L); + + DDSpanContext ctx2 = mock(DDSpanContext.class); + PendingTrace pendingTrace2 = mock(PendingTrace.class); + when(ctx2.getTraceCollector()).thenReturn(pendingTrace2); + when(pendingTrace2.getCurrentTimeNano()).thenReturn(0L); + + DDSpan span1 = (DDSpan) createMethod.invoke(null, "test", 0L, ctx1, Collections.emptyList()); + DDSpan span2 = (DDSpan) createMethod.invoke(null, "test", 0L, ctx2, Collections.emptyList()); + + AtomicBoolean processedSpan1 = new AtomicBoolean(false); + AtomicBoolean processedSpan2 = new AtomicBoolean(false); + + SpanPostProcessor mockProcessor = mock(SpanPostProcessor.class); + doAnswer( + inv -> { + Object spanArg = inv.getArgument(0); + if (spanArg == span1) processedSpan1.set(true); + if (spanArg == span2) processedSpan2.set(true); + return null; + }) + .when(mockProcessor) + .process(any(), any()); + + SpanPostProcessor.Holder.INSTANCE = mockProcessor; + + try (TraceProcessingWorker worker = + new TraceProcessingWorker( + 10, + healthMetrics, + countingDispatcher, + () -> false, + FAST_LANE, + 100, + TimeUnit.SECONDS, + null)) { + worker.start(); + // traces are submitted + List trace = new ArrayList<>(); + trace.add(span1); + trace.add(span2); + worker.publish(span1, SAMPLER_KEEP, trace); + worker.publish(span2, SAMPLER_KEEP, trace); + + // traces are passed through unless rejected on submission + long deadline = System.currentTimeMillis() + 5000; + while (System.currentTimeMillis() < deadline) { + if (processedSpan1.get() && processedSpan2.get()) break; + Thread.sleep(50); + } + assertTrue(processedSpan1.get()); + assertTrue(processedSpan2.get()); + } finally { + SpanPostProcessor.Holder.INSTANCE = SpanPostProcessor.Holder.NOOP; + } + } + + // ------------------------------------------------------------------------- + // Test 6: traces should be processed + // ------------------------------------------------------------------------- + + @TableTest({ + "scenario | priority | traceCount", + "sampler drop x1 | PrioritySampling.SAMPLER_DROP | 1 ", + "user drop x1 | PrioritySampling.USER_DROP | 1 ", + "sampler keep x1 | PrioritySampling.SAMPLER_KEEP | 1 ", + "user keep x1 | PrioritySampling.USER_KEEP | 1 ", + "unset x1 | PrioritySampling.UNSET | 1 ", + "sampler drop x10 | PrioritySampling.SAMPLER_DROP | 10 ", + "user drop x10 | PrioritySampling.USER_DROP | 10 ", + "sampler keep x10 | PrioritySampling.SAMPLER_KEEP | 10 ", + "user keep x10 | PrioritySampling.USER_KEEP | 10 ", + "unset x10 | PrioritySampling.UNSET | 10 ", + "sampler drop x20 | PrioritySampling.SAMPLER_DROP | 20 ", + "user drop x20 | PrioritySampling.USER_DROP | 20 ", + "sampler keep x20 | PrioritySampling.SAMPLER_KEEP | 20 ", + "user keep x20 | PrioritySampling.USER_KEEP | 20 ", + "unset x20 | PrioritySampling.UNSET | 20 ", + "sampler drop x100 | PrioritySampling.SAMPLER_DROP | 100 ", + "user drop x100 | PrioritySampling.USER_DROP | 100 ", + "sampler keep x100 | PrioritySampling.SAMPLER_KEEP | 100 ", + "user keep x100 | PrioritySampling.USER_KEEP | 100 ", + "unset x100 | PrioritySampling.UNSET | 100 " + }) + void testTracesShouldBeProcessed( + @ConvertWith(PrioritySamplingConverter.class) int priority, int traceCount) throws Exception { + AtomicInteger acceptedCount = new AtomicInteger(); + PayloadDispatcherImpl countingDispatcher = mock(PayloadDispatcherImpl.class); + doAnswer( + inv -> { + acceptedCount.getAndIncrement(); + return null; + }) + .when(countingDispatcher) + .addTrace(any()); + HealthMetrics healthMetrics = mock(HealthMetrics.class); + // prevent heartbeats from helping the flush happen + + try (TraceProcessingWorker worker = + new TraceProcessingWorker( + 10, + healthMetrics, + countingDispatcher, + () -> false, + FAST_LANE, + 100, + TimeUnit.SECONDS, // prevent heartbeats from helping the flush happen + null)) { + worker.start(); + // traces are submitted + int submitted = 0; + for (int i = 0; i < traceCount; ++i) { + PublishResult publishResult = + worker.publish( + mock(DDSpan.class), priority, Collections.singletonList(mock(DDSpan.class))); + submitted += publishResult == ENQUEUED_FOR_SERIALIZATION ? 1 : 0; + } + + // traces are passed through unless rejected on submission + int expectedSubmitted = submitted; + long deadline = System.currentTimeMillis() + 5000; + while (System.currentTimeMillis() < deadline) { + if (expectedSubmitted == acceptedCount.get()) break; + Thread.sleep(50); + } + assertEquals(expectedSubmitted, acceptedCount.get()); + } + // cleanup + } + + // ------------------------------------------------------------------------- + // Test 7: flush of full queue after worker thread stopped will not flush but will return + // ------------------------------------------------------------------------- + + @Test + void testFlushOfFullQueueAfterWorkerThreadStoppedWillNotFlushButWillReturn() { + PayloadDispatcherImpl countingDispatcher = mock(PayloadDispatcherImpl.class); + HealthMetrics healthMetrics = mock(HealthMetrics.class); + TraceProcessingWorker worker = + new TraceProcessingWorker( + 10, + healthMetrics, + countingDispatcher, + () -> false, + FAST_LANE, + 100, + TimeUnit.SECONDS, + null); + worker.start(); + worker.close(); + + while (worker.getPrimaryQueue().offer(Collections.singletonList(mock(DDSpan.class)))) { + // fill the queue + } + + boolean flushed = worker.flush(1, TimeUnit.SECONDS); + assertFalse(flushed); + } + + // ------------------------------------------------------------------------- + // Test 8: send unsampled traces - dropping policy is active + // ------------------------------------------------------------------------- + + @TableTest({ + "scenario | priority | traceCount | acceptedTraces | acceptedSpans | sampledSingleSpans | spanCount", + "sampler drop 1 trace 1 span | PrioritySampling.SAMPLER_DROP | 1 | 1 | 1 | 1 | 1 ", + "user drop 1 trace 2 spans | PrioritySampling.USER_DROP | 1 | 1 | 1 | 1 | 2 ", + "sampler drop 1 trace 3 spans | PrioritySampling.SAMPLER_DROP | 1 | 1 | 2 | 2 | 3 ", + "user drop 1 trace 4 spans | PrioritySampling.USER_DROP | 1 | 1 | 2 | 2 | 4 ", + "sampler drop 1 trace 5 spans | PrioritySampling.SAMPLER_DROP | 1 | 1 | 3 | 3 | 5 ", + "user drop 2 traces 1 span | PrioritySampling.USER_DROP | 2 | 1 | 1 | 1 | 1 ", + "sampler drop 2 traces 2 spans | PrioritySampling.SAMPLER_DROP | 2 | 2 | 2 | 2 | 2 ", + "user drop 2 traces 3 spans | PrioritySampling.USER_DROP | 2 | 2 | 3 | 3 | 3 ", + "sampler drop 2 traces 4 spans | PrioritySampling.SAMPLER_DROP | 2 | 2 | 4 | 4 | 4 ", + "user drop 2 traces 5 spans | PrioritySampling.USER_DROP | 2 | 2 | 5 | 5 | 5 ", + "sampler drop 10 traces 1 span | PrioritySampling.SAMPLER_DROP | 10 | 5 | 5 | 5 | 1 ", + "user drop 10 traces 2 spans | PrioritySampling.USER_DROP | 10 | 10 | 10 | 10 | 2 ", + "sampler drop 10 traces 3 spans | PrioritySampling.SAMPLER_DROP | 10 | 10 | 15 | 15 | 3 ", + "user drop 10 traces 4 spans | PrioritySampling.USER_DROP | 10 | 10 | 20 | 20 | 4 ", + "sampler drop 10 traces 5 spans | PrioritySampling.SAMPLER_DROP | 10 | 10 | 25 | 25 | 5 ", + "sampler keep 1 trace 1 span | PrioritySampling.SAMPLER_KEEP | 1 | 1 | 1 | 0 | 1 ", + "user keep 1 trace 2 spans | PrioritySampling.USER_KEEP | 1 | 1 | 2 | 0 | 2 ", + "sampler keep 1 trace 3 spans | PrioritySampling.SAMPLER_KEEP | 1 | 1 | 3 | 0 | 3 ", + "user keep 1 trace 4 spans | PrioritySampling.USER_KEEP | 1 | 1 | 4 | 0 | 4 ", + "sampler keep 1 trace 5 spans | PrioritySampling.SAMPLER_KEEP | 1 | 1 | 5 | 0 | 5 ", + "user keep 2 traces 1 span | PrioritySampling.USER_KEEP | 2 | 2 | 2 | 0 | 1 ", + "sampler keep 2 traces 2 spans | PrioritySampling.SAMPLER_KEEP | 2 | 2 | 4 | 0 | 2 ", + "user keep 2 traces 3 spans | PrioritySampling.USER_KEEP | 2 | 2 | 6 | 0 | 3 ", + "sampler keep 2 traces 4 spans | PrioritySampling.SAMPLER_KEEP | 2 | 2 | 8 | 0 | 4 ", + "user keep 2 traces 5 spans | PrioritySampling.USER_KEEP | 2 | 2 | 10 | 0 | 5 ", + "sampler keep 10 traces 1 span | PrioritySampling.SAMPLER_KEEP | 10 | 10 | 10 | 0 | 1 ", + "user keep 10 traces 2 spans | PrioritySampling.USER_KEEP | 10 | 10 | 20 | 0 | 2 ", + "sampler keep 10 traces 3 spans | PrioritySampling.SAMPLER_KEEP | 10 | 10 | 30 | 0 | 3 ", + "user keep 10 traces 4 spans | PrioritySampling.USER_KEEP | 10 | 10 | 40 | 0 | 4 ", + "sampler keep 10 traces 5 spans | PrioritySampling.SAMPLER_KEEP | 10 | 10 | 50 | 0 | 5 " + }) + void testSendUnsampledTracesDroppingPolicyIsActive( + @ConvertWith(PrioritySamplingConverter.class) int priority, + int traceCount, + int acceptedTraces, + int acceptedSpans, + int sampledSingleSpans, + int spanCount) + throws Exception { + HealthMetrics healthMetrics = mock(HealthMetrics.class); + AtomicInteger acceptedCount = new AtomicInteger(); + AtomicInteger acceptedSpanCount = new AtomicInteger(); + PayloadDispatcherImpl countingDispatcher = mock(PayloadDispatcherImpl.class); + doAnswer( + inv -> { + List traceList = inv.getArgument(0); + acceptedSpanCount.getAndAdd(traceList.size()); + acceptedCount.getAndIncrement(); + return null; + }) + .when(countingDispatcher) + .addTrace(any()); + + AtomicInteger sampledSpansCount = new AtomicInteger(); + // drop every other span + SingleSpanSampler singleSpanSampler = + new SingleSpanSampler() { + int counter = 0; + + @Override + public > boolean setSamplingPriority(T span) { + if (counter++ % 2 == 0) { + sampledSpansCount.incrementAndGet(); + return true; + } + return false; + } + }; + + try (TraceProcessingWorker worker = + new TraceProcessingWorker( + 10, + healthMetrics, + countingDispatcher, + () -> true, + FAST_LANE, + 100, + TimeUnit.SECONDS, + singleSpanSampler)) { + worker.start(); + // traces are submitted + for (int i = 0; i < traceCount; ++i) { + List trace = new ArrayList<>(); + for (int j = 0; j < spanCount; j++) { + trace.add(mock(DDSpan.class)); + } + worker.publish(trace.get(0), priority, trace); + } + + // traces are passed through unless rejected on submission + long deadline = System.currentTimeMillis() + 5000; + while (System.currentTimeMillis() < deadline) { + if (acceptedTraces == acceptedCount.get() + && acceptedSpans == acceptedSpanCount.get() + && sampledSingleSpans == sampledSpansCount.get()) break; + Thread.sleep(50); + } + assertEquals(acceptedTraces, acceptedCount.get()); + assertEquals(acceptedSpans, acceptedSpanCount.get()); + assertEquals(sampledSingleSpans, sampledSpansCount.get()); + } + } + + // ------------------------------------------------------------------------- + // Test 9: send unsampled traces - dropping policy is inactive + // ------------------------------------------------------------------------- + + @TableTest({ + "scenario | priority | traceCount | expectedChunks | expectedSpans | sampledSingleSpans | spanCount", + "sampler drop 1 trace 1 span | PrioritySampling.SAMPLER_DROP | 1 | 1 | 1 | 1 | 1 ", + "user drop 1 trace 2 spans | PrioritySampling.USER_DROP | 1 | 2 | 2 | 1 | 2 ", + "sampler drop 1 trace 3 spans | PrioritySampling.SAMPLER_DROP | 1 | 2 | 3 | 2 | 3 ", + "user drop 1 trace 4 spans | PrioritySampling.USER_DROP | 1 | 2 | 4 | 2 | 4 ", + "sampler drop 1 trace 5 spans | PrioritySampling.SAMPLER_DROP | 1 | 2 | 5 | 3 | 5 ", + "user drop 2 traces 1 span | PrioritySampling.USER_DROP | 2 | 2 | 2 | 1 | 1 ", + "sampler drop 2 traces 2 spans | PrioritySampling.SAMPLER_DROP | 2 | 4 | 4 | 2 | 2 ", + "user drop 2 traces 3 spans | PrioritySampling.USER_DROP | 2 | 4 | 6 | 3 | 3 ", + "sampler drop 2 traces 4 spans | PrioritySampling.SAMPLER_DROP | 2 | 4 | 8 | 4 | 4 ", + "user drop 2 traces 5 spans | PrioritySampling.USER_DROP | 2 | 4 | 10 | 5 | 5 ", + "user drop 10 traces 1 span | PrioritySampling.USER_DROP | 10 | 10 | 10 | 5 | 1 ", + "sampler drop 10 traces 2 spans | PrioritySampling.SAMPLER_DROP | 10 | 20 | 20 | 10 | 2 ", + "user drop 10 traces 3 spans | PrioritySampling.USER_DROP | 10 | 20 | 30 | 15 | 3 ", + "sampler drop 10 traces 4 spans | PrioritySampling.SAMPLER_DROP | 10 | 20 | 40 | 20 | 4 ", + "user drop 10 traces 5 spans | PrioritySampling.USER_DROP | 10 | 20 | 50 | 25 | 5 ", + "sampler keep 1 trace 1 span | PrioritySampling.SAMPLER_KEEP | 1 | 1 | 1 | 0 | 1 ", + "user keep 1 trace 2 spans | PrioritySampling.USER_KEEP | 1 | 1 | 2 | 0 | 2 ", + "sampler keep 1 trace 3 spans | PrioritySampling.SAMPLER_KEEP | 1 | 1 | 3 | 0 | 3 ", + "user keep 1 trace 4 spans | PrioritySampling.USER_KEEP | 1 | 1 | 4 | 0 | 4 ", + "sampler keep 1 trace 5 spans | PrioritySampling.SAMPLER_KEEP | 1 | 1 | 5 | 0 | 5 ", + "user keep 2 traces 1 span | PrioritySampling.USER_KEEP | 2 | 2 | 2 | 0 | 1 ", + "sampler keep 2 traces 2 spans | PrioritySampling.SAMPLER_KEEP | 2 | 2 | 4 | 0 | 2 ", + "user keep 2 traces 3 spans | PrioritySampling.USER_KEEP | 2 | 2 | 6 | 0 | 3 ", + "sampler keep 2 traces 4 spans | PrioritySampling.SAMPLER_KEEP | 2 | 2 | 8 | 0 | 4 ", + "user keep 2 traces 5 spans | PrioritySampling.USER_KEEP | 2 | 2 | 10 | 0 | 5 ", + "sampler keep 10 traces 1 span | PrioritySampling.SAMPLER_KEEP | 10 | 10 | 10 | 0 | 1 ", + "user keep 10 traces 2 spans | PrioritySampling.USER_KEEP | 10 | 10 | 20 | 0 | 2 ", + "sampler keep 10 traces 3 spans | PrioritySampling.SAMPLER_KEEP | 10 | 10 | 30 | 0 | 3 ", + "user keep 10 traces 4 spans | PrioritySampling.USER_KEEP | 10 | 10 | 40 | 0 | 4 ", + "sampler keep 10 traces 5 spans | PrioritySampling.SAMPLER_KEEP | 10 | 10 | 50 | 0 | 5 " + }) + void testSendUnsampledTracesDroppingPolicyIsInactive( + @ConvertWith(PrioritySamplingConverter.class) int priority, + int traceCount, + int expectedChunks, + int expectedSpans, + int sampledSingleSpans, + int spanCount) + throws Exception { + HealthMetrics healthMetrics = mock(HealthMetrics.class); + AtomicInteger chunksCount = new AtomicInteger(); + AtomicInteger spansCount = new AtomicInteger(); + PayloadDispatcherImpl countingDispatcher = mock(PayloadDispatcherImpl.class); + doAnswer( + inv -> { + List traceList = inv.getArgument(0); + spansCount.getAndAdd(traceList.size()); + chunksCount.getAndIncrement(); + return null; + }) + .when(countingDispatcher) + .addTrace(any()); + + AtomicInteger sampledSpansCount = new AtomicInteger(); + // drop every other span + SingleSpanSampler singleSpanSampler = + new SingleSpanSampler() { + int counter = 0; + + @Override + public > boolean setSamplingPriority(T span) { + if (counter++ % 2 == 0) { + sampledSpansCount.incrementAndGet(); + return true; + } + return false; + } + }; + + try (TraceProcessingWorker worker = + new TraceProcessingWorker( + 10, + healthMetrics, + countingDispatcher, + () -> false, + FAST_LANE, + 100, + TimeUnit.SECONDS, + singleSpanSampler)) { + worker.start(); + // traces are submitted + for (int i = 0; i < traceCount; ++i) { + List trace = new ArrayList<>(); + for (int j = 0; j < spanCount; j++) { + trace.add(mock(DDSpan.class)); + } + worker.publish(trace.get(0), priority, trace); + } + + // traces are passed through unless rejected on submission + long deadline = System.currentTimeMillis() + 5000; + while (System.currentTimeMillis() < deadline) { + if (expectedChunks == chunksCount.get() + && expectedSpans == spansCount.get() + && sampledSingleSpans == sampledSpansCount.get()) break; + Thread.sleep(50); + } + assertEquals(expectedChunks, chunksCount.get()); + assertEquals(expectedSpans, spansCount.get()); + assertEquals(sampledSingleSpans, sampledSpansCount.get()); + } + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/common/writer/WriterFactoryTest.java b/dd-trace-core/src/test/java/datadog/trace/common/writer/WriterFactoryTest.java new file mode 100644 index 00000000000..bb52f69806a --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/common/writer/WriterFactoryTest.java @@ -0,0 +1,295 @@ +package datadog.trace.common.writer; + +import static datadog.trace.api.config.TracerConfig.PRIORITIZATION_TYPE; +import static java.util.Collections.singletonList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import datadog.communication.ddagent.DDAgentFeaturesDiscovery; +import datadog.communication.ddagent.SharedCommunicationObjects; +import datadog.trace.api.Config; +import datadog.trace.api.config.OtlpConfig; +import datadog.trace.api.intake.TrackType; +import datadog.trace.common.sampling.Sampler; +import datadog.trace.common.writer.ddagent.Prioritization; +import datadog.trace.core.monitor.HealthMetrics; +import datadog.trace.test.util.DDJavaSpecification; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import okhttp3.Call; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.tabletest.junit.TableTest; + +class WriterFactoryTest extends DDJavaSpecification { + + @TableTest({ + "scenario | configuredType | hasEvpProxy | evpProxySupportsCompression | isCiVisibilityAgentlessEnabled | expectedWriterClass | expectedApiClass | isCompressionEnabled", + "LoggingWriter agentless | LoggingWriter | true | false | true | datadog.trace.common.writer.LoggingWriter | | false ", + "PrintingWriter agentless | PrintingWriter | true | false | true | datadog.trace.common.writer.PrintingWriter | | false ", + "TraceStructureWriter agentless | TraceStructureWriter | true | false | true | datadog.trace.common.writer.TraceStructureWriter | | false ", + "MultiWriter agentless | 'MultiWriter:LoggingWriter,PrintingWriter' | true | false | true | datadog.trace.common.writer.MultiWriter | | false ", + "DDIntakeWriter evp agentless | DDIntakeWriter | true | false | true | datadog.trace.common.writer.DDIntakeWriter | datadog.trace.common.writer.ddintake.DDIntakeApi | true ", + "DDIntakeWriter evp not agentless | DDIntakeWriter | true | false | false | datadog.trace.common.writer.DDIntakeWriter | datadog.trace.common.writer.ddintake.DDEvpProxyApi | false ", + "DDIntakeWriter no evp agentless | DDIntakeWriter | false | false | true | datadog.trace.common.writer.DDIntakeWriter | datadog.trace.common.writer.ddintake.DDIntakeApi | true ", + "DDIntakeWriter no evp not agentless | DDIntakeWriter | false | false | false | datadog.trace.common.writer.DDIntakeWriter | datadog.trace.common.writer.ddintake.DDIntakeApi | true ", + "DDAgentWriter evp agentless | DDAgentWriter | true | false | true | datadog.trace.common.writer.DDIntakeWriter | datadog.trace.common.writer.ddintake.DDIntakeApi | true ", + "DDAgentWriter evp not agentless | DDAgentWriter | true | false | false | datadog.trace.common.writer.DDIntakeWriter | datadog.trace.common.writer.ddintake.DDEvpProxyApi | false ", + "DDAgentWriter evp compression not agentless | DDAgentWriter | true | true | false | datadog.trace.common.writer.DDIntakeWriter | datadog.trace.common.writer.ddintake.DDEvpProxyApi | true ", + "DDAgentWriter no evp agentless | DDAgentWriter | false | false | true | datadog.trace.common.writer.DDIntakeWriter | datadog.trace.common.writer.ddintake.DDIntakeApi | true ", + "DDAgentWriter no evp not agentless | DDAgentWriter | false | false | false | datadog.trace.common.writer.DDAgentWriter | datadog.trace.common.writer.ddagent.DDAgentApi | false ", + "not-found evp agentless | 'not-found' | true | false | true | datadog.trace.common.writer.DDIntakeWriter | datadog.trace.common.writer.ddintake.DDIntakeApi | true ", + "not-found evp not agentless | 'not-found' | true | false | false | datadog.trace.common.writer.DDIntakeWriter | datadog.trace.common.writer.ddintake.DDEvpProxyApi | false ", + "not-found no evp agentless | 'not-found' | false | false | true | datadog.trace.common.writer.DDIntakeWriter | datadog.trace.common.writer.ddintake.DDIntakeApi | true ", + "not-found no evp not agentless | 'not-found' | false | false | false | datadog.trace.common.writer.DDAgentWriter | datadog.trace.common.writer.ddagent.DDAgentApi | false " + }) + void testWriterCreationForCiVisibility( + String configuredType, + boolean hasEvpProxy, + boolean evpProxySupportsCompression, + boolean isCiVisibilityAgentlessEnabled, + Class expectedWriterClass, + Class expectedApiClass, + boolean isCompressionEnabled) + throws Exception { + Config config = mock(Config.class); + when(config.getApiKey()).thenReturn("my-api-key"); + when(config.getAgentUrl()).thenReturn("http://my-agent.url"); + //noinspection unchecked + doReturn(Prioritization.FAST_LANE) + .when(config) + .getEnumValue( + eq(PRIORITIZATION_TYPE), + (Class) any(Class.class), + any(Prioritization.class)); + when(config.isTracerMetricsEnabled()).thenReturn(true); + when(config.isCiVisibilityEnabled()).thenReturn(true); + when(config.isCiVisibilityCodeCoverageEnabled()).thenReturn(false); + when(config.isCiVisibilityAgentlessEnabled()).thenReturn(isCiVisibilityAgentlessEnabled); + + // Mock agent info response + Response response = + buildHttpResponse( + hasEvpProxy, evpProxySupportsCompression, HttpUrl.parse("http://my-agent.url/info")); + + // Mock HTTP client that simulates delayed response for async feature discovery + Call mockCall = mock(Call.class); + OkHttpClient mockHttpClient = mock(OkHttpClient.class); + when(mockCall.execute()) + .thenAnswer( + inv -> { + // Add a delay + Thread.sleep(400); + return response; + }); + when(mockHttpClient.newCall(any(Request.class))).thenReturn(mockCall); + + // Create SharedCommunicationObjects with mocked HTTP client + SharedCommunicationObjects sharedComm = new SharedCommunicationObjects(); + sharedComm.agentHttpClient = mockHttpClient; + sharedComm.agentUrl = HttpUrl.parse("http://my-agent.url"); + sharedComm.createRemaining(config); + Sampler sampler = mock(Sampler.class); + + Writer writer = + WriterFactory.createWriter( + config, sharedComm, sampler, null, HealthMetrics.NO_OP, configuredType); + + List> expectedApiClasses = + expectedApiClass != null ? singletonList(expectedApiClass) : null; + Collection apis; + List> apiClasses; + if (expectedApiClasses != null) { + apis = ((RemoteWriter) writer).getApis(); + apiClasses = apis.stream().map(Object::getClass).collect(Collectors.toList()); + } else { + apis = java.util.Collections.emptyList(); + apiClasses = java.util.Collections.emptyList(); + } + + assertEquals(expectedWriterClass, writer.getClass()); + assertTrue(expectedApiClasses == null || apiClasses.equals(expectedApiClasses)); + assertTrue( + expectedApiClasses == null + || apis.stream().allMatch(api -> api.isCompressionEnabled() == isCompressionEnabled)); + } + + @TableTest({ + "scenario | configuredType | agentRunning | hasEvpProxy | isLlmObsAgentlessEnabled | expectedWriterClass | expectedLlmObsApiClass ", + "evp proxy not agentless | DDIntakeWriter | true | true | false | datadog.trace.common.writer.DDIntakeWriter | datadog.trace.common.writer.ddintake.DDEvpProxyApi", + "no evp not agentless | DDIntakeWriter | true | false | false | datadog.trace.common.writer.DDIntakeWriter | datadog.trace.common.writer.ddintake.DDIntakeApi ", + "agent not running not agentless | DDIntakeWriter | false | false | false | datadog.trace.common.writer.DDIntakeWriter | datadog.trace.common.writer.ddintake.DDIntakeApi ", + "evp proxy agentless | DDIntakeWriter | true | true | true | datadog.trace.common.writer.DDIntakeWriter | datadog.trace.common.writer.ddintake.DDIntakeApi ", + "no evp agentless | DDIntakeWriter | true | false | true | datadog.trace.common.writer.DDIntakeWriter | datadog.trace.common.writer.ddintake.DDIntakeApi ", + "agent not running agentless | DDIntakeWriter | false | false | true | datadog.trace.common.writer.DDIntakeWriter | datadog.trace.common.writer.ddintake.DDIntakeApi " + }) + void testWriterCreationForLlmObservability( + String configuredType, + boolean agentRunning, + boolean hasEvpProxy, + boolean isLlmObsAgentlessEnabled, + Class expectedWriterClass, + Class expectedLlmObsApiClass) + throws Exception { + Config config = mock(Config.class); + when(config.getApiKey()).thenReturn("my-api-key"); + when(config.getAgentUrl()).thenReturn("http://my-agent.url"); + //noinspection unchecked + doReturn(Prioritization.FAST_LANE) + .when(config) + .getEnumValue( + eq(PRIORITIZATION_TYPE), + (Class) any(Class.class), + any(Prioritization.class)); + when(config.isTracerMetricsEnabled()).thenReturn(true); + when(config.isLlmObsEnabled()).thenReturn(true); + when(config.isLlmObsAgentlessEnabled()).thenReturn(isLlmObsAgentlessEnabled); + + // Mock agent info response + Response response; + if (agentRunning) { + response = buildHttpResponse(hasEvpProxy, true, HttpUrl.parse("http://my-agent.url/info")); + } else { + response = buildHttpResponseNotOk(HttpUrl.parse("http://my-agent.url/info")); + } + + // Mock HTTP client that simulates delayed response for async feature discovery + Call mockCall = mock(Call.class); + OkHttpClient mockHttpClient = mock(OkHttpClient.class); + when(mockCall.execute()) + .thenAnswer( + inv -> { + // Add a delay + Thread.sleep(400); + return response; + }); + when(mockHttpClient.newCall(any(Request.class))).thenReturn(mockCall); + + // Create SharedCommunicationObjects with mocked HTTP client + SharedCommunicationObjects sharedComm = new SharedCommunicationObjects(); + sharedComm.agentHttpClient = mockHttpClient; + sharedComm.agentUrl = HttpUrl.parse("http://my-agent.url"); + sharedComm.createRemaining(config); + Sampler sampler = mock(Sampler.class); + + Writer writer = + WriterFactory.createWriter( + config, sharedComm, sampler, null, HealthMetrics.NO_OP, configuredType); + List> llmObsApiClasses = + ((RemoteWriter) writer) + .getApis().stream() + .filter( + api -> { + try { + Field trackTypeField = api.getClass().getDeclaredField("trackType"); + trackTypeField.setAccessible(true); + return trackTypeField.get(api) == TrackType.LLMOBS; + } catch (Exception e) { + return false; + } + }) + .map(Object::getClass) + .collect(Collectors.toList()); + + assertEquals(expectedWriterClass, writer.getClass()); + assertEquals(singletonList(expectedLlmObsApiClass), llmObsApiClasses); + } + + @TableTest({ + "scenario | protocol | compression | endpoint | expectedSenderClass | expectedUrl | expectedGzip", + "http no gzip | HTTP_PROTOBUF | NONE | 'http://otel-collector:4318/v1/traces' | datadog.trace.core.otlp.common.OtlpHttpSender | 'http://otel-collector:4318/v1/traces' | false ", + "http gzip | HTTP_PROTOBUF | GZIP | 'http://otel-collector:4318/v1/traces' | datadog.trace.core.otlp.common.OtlpHttpSender | 'http://otel-collector:4318/v1/traces' | true ", + "grpc no gzip | GRPC | NONE | 'http://otel-collector:4317' | datadog.trace.core.otlp.common.OtlpGrpcSender | 'http://otel-collector:4317/opentelemetry.proto.collector.trace.v1.TraceService/Export' | false " + }) + void testWriterCreationForOtlpWriter( + OtlpConfig.Protocol protocol, + OtlpConfig.Compression compression, + String endpoint, + Class expectedSenderClass, + String expectedUrl, + boolean expectedGzip) + throws Exception { + Config config = mock(Config.class); + Map headers = new HashMap<>(); + headers.put("api-key", "secret"); + + when(config.getTraceFlushIntervalSeconds()).thenReturn(1.0f); + when(config.getOtlpTracesEndpoint()).thenReturn(endpoint); + when(config.getOtlpTracesHeaders()).thenReturn(headers); + when(config.getOtlpTracesProtocol()).thenReturn(protocol); + when(config.getOtlpTracesCompression()).thenReturn(compression); + when(config.getOtlpTracesTimeout()).thenReturn(5000); + + // OTLP branch in WriterFactory does not consult sharedComm or sampler, so nulls are safe here. + Writer writer = + WriterFactory.createWriter(config, null, null, null, HealthMetrics.NO_OP, "OtlpWriter"); + Object sender = readField(writer, "sender"); + + assertEquals(OtlpWriter.class, writer.getClass()); + assertEquals(expectedSenderClass, sender.getClass()); + assertEquals(expectedUrl, readField(sender, "url").toString()); + assertEquals(headers, readField(sender, "headers")); + assertEquals(expectedGzip, readField(sender, "gzip")); + + writer.close(); + } + + private static Response buildHttpResponse( + boolean hasEvpProxy, boolean evpProxySupportsCompression, HttpUrl agentUrl) { + List endpoints = new ArrayList<>(); + if (hasEvpProxy && evpProxySupportsCompression) { + endpoints.add(DDAgentFeaturesDiscovery.V4_EVP_PROXY_ENDPOINT); + } else if (hasEvpProxy) { + endpoints.add(DDAgentFeaturesDiscovery.V2_EVP_PROXY_ENDPOINT); + } else { + endpoints.add(DDAgentFeaturesDiscovery.V04_ENDPOINT); + } + + StringBuilder endpointsJson = new StringBuilder("["); + for (int i = 0; i < endpoints.size(); i++) { + if (i > 0) endpointsJson.append(","); + endpointsJson.append("\"").append(endpoints.get(i)).append("\""); + } + endpointsJson.append("]"); + String json = "{\"version\":\"7.40.0\",\"endpoints\":" + endpointsJson + "}"; + + return new Response.Builder() + .code(200) + .message("OK") + .protocol(Protocol.HTTP_1_1) + .request(new Request.Builder().url(agentUrl.resolve("/info")).build()) + .body(ResponseBody.create(MediaType.parse("application/json"), json)) + .build(); + } + + private static Response buildHttpResponseNotOk(HttpUrl agentUrl) { + return new Response.Builder() + .code(500) + .message("ERROR") + .protocol(Protocol.HTTP_1_1) + .request(new Request.Builder().url(agentUrl.resolve("/info")).build()) + .body(ResponseBody.create(MediaType.parse("application/json"), "")) + .build(); + } + + private static Object readField(Object instance, String fieldName) throws Exception { + Field field = instance.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(instance); + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/common/writer/ddintake/DDEvpProxyApiTest.java b/dd-trace-core/src/test/java/datadog/trace/common/writer/ddintake/DDEvpProxyApiTest.java new file mode 100644 index 00000000000..c3464d47d36 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/common/writer/ddintake/DDEvpProxyApiTest.java @@ -0,0 +1,457 @@ +package datadog.trace.common.writer.ddintake; + +import static datadog.communication.ddagent.DDAgentFeaturesDiscovery.V2_EVP_PROXY_ENDPOINT; +import static datadog.communication.ddagent.DDAgentFeaturesDiscovery.V4_EVP_PROXY_ENDPOINT; +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static java.util.Collections.singletonMap; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import datadog.communication.serialization.ByteBufferConsumer; +import datadog.communication.serialization.FlushingBuffer; +import datadog.communication.serialization.msgpack.MsgPackWriter; +import datadog.trace.agent.test.server.http.JavaTestHttpServer; +import datadog.trace.api.DDTags; +import datadog.trace.api.civisibility.CiVisibilityWellKnownTags; +import datadog.trace.api.intake.TrackType; +import datadog.trace.bootstrap.instrumentation.api.InternalSpanTypes; +import datadog.trace.bootstrap.instrumentation.api.ServiceNameSources; +import datadog.trace.bootstrap.instrumentation.api.Tags; +import datadog.trace.common.writer.Payload; +import datadog.trace.common.writer.RemoteApi; +import datadog.trace.common.writer.RemoteMapper; +import datadog.trace.core.DDCoreJavaSpecification; +import datadog.trace.core.DDSpan; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.zip.GZIPInputStream; +import okhttp3.HttpUrl; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.msgpack.jackson.dataformat.MessagePackFactory; + +@Timeout(20) +class DDEvpProxyApiTest extends DDCoreJavaSpecification { + + static final CiVisibilityWellKnownTags WELL_KNOWN_TAGS = + new CiVisibilityWellKnownTags( + "my-runtime-id", + "my-env", + "my-language", + "my-runtime-name", + "my-runtime-version", + "my-runtime-vendor", + "my-os-arch", + "my-os-platform", + "my-os-version", + "false"); + + static final String INTAKE_SUBDOMAIN = "citestcycle-intake"; + static final ObjectMapper MSG_PACK_MAPPER = new ObjectMapper(new MessagePackFactory()); + static final String DD_EVP_SUBDOMAIN_HEADER = "X-Datadog-EVP-Subdomain"; + + @Test + void testSendingAnEmptyListOfTracesReturnsNoErrors() { + TrackType trackType = TrackType.CITESTCYCLE; + String apiVersion = "v2"; + String evpProxyEndpoint = V2_EVP_PROXY_ENDPOINT; + String path = buildAgentEvpProxyPath(evpProxyEndpoint, trackType, apiVersion); + JavaTestHttpServer agentEvpProxy = + JavaTestHttpServer.httpServer( + s -> + s.handlers( + h -> + h.post( + path, + api -> { + if (!"application/msgpack" + .equals(api.getRequest().getContentType())) { + api.getResponse() + .status(400) + .send("wrong type: " + api.getRequest().getContentType()); + } else { + api.getResponse().status(200).send(); + } + }))); + DDEvpProxyApi client = + createEvpProxyApi( + agentEvpProxy.getAddress().toString(), evpProxyEndpoint, trackType, false); + Payload payload = prepareTraces(trackType, false, Collections.emptyList()); + + try { + RemoteApi.Response clientResponse = client.sendSerializedTraces(payload); + assertTrue(clientResponse.success()); + assertTrue(clientResponse.status().isPresent()); + assertEquals(200, clientResponse.status().getAsInt()); + assertEquals(path, agentEvpProxy.getLastRequest().getPath()); + assertEquals( + INTAKE_SUBDOMAIN, agentEvpProxy.getLastRequest().getHeader(DD_EVP_SUBDOMAIN_HEADER)); + } finally { + agentEvpProxy.close(); + } + } + + @Test + void testRetriesWhenBackendReturns5xx() { + TrackType trackType = TrackType.CITESTCYCLE; + String apiVersion = "v2"; + String evpProxyEndpoint = V2_EVP_PROXY_ENDPOINT; + String path = buildAgentEvpProxyPath(evpProxyEndpoint, trackType, apiVersion); + int[] retry = {1}; + JavaTestHttpServer agentEvpProxy = + JavaTestHttpServer.httpServer( + s -> + s.handlers( + h -> + h.post( + path, + api -> { + if (retry[0] < 5) { + api.getResponse().status(503).send(); + retry[0]++; + } else { + api.getResponse().status(200).send(); + } + }))); + DDEvpProxyApi client = + createEvpProxyApi( + agentEvpProxy.getAddress().toString(), evpProxyEndpoint, trackType, false); + Payload payload = prepareTraces(trackType, false, Collections.emptyList()); + + try { + RemoteApi.Response clientResponse = client.sendSerializedTraces(payload); + assertTrue(clientResponse.success()); + assertTrue(clientResponse.status().isPresent()); + assertEquals(200, clientResponse.status().getAsInt()); + assertEquals(path, agentEvpProxy.getLastRequest().getPath()); + assertEquals( + INTAKE_SUBDOMAIN, agentEvpProxy.getLastRequest().getHeader(DD_EVP_SUBDOMAIN_HEADER)); + } finally { + agentEvpProxy.close(); + } + } + + @Test + void testContentIsSentAsMsgpackEmptyTraces() throws IOException { + runContentIsSentAsMsgpackTest( + TrackType.CITESTCYCLE, "v2", V2_EVP_PROXY_ENDPOINT, false, emptyList(), emptyMap()); + } + + @Test + void testContentIsSentAsMsgpackFakeTypeSpan() throws IOException { + DDSpan span = buildSpan(1L, "fakeType", Collections.singletonMap("service.name", "my-service")); + span.finish(); + setDurationNano(span, 10L); + List> traces = singletonList(singletonList(span)); + + Map metadata = buildMetadataMap(); + Map content = new TreeMap<>(); + content.put("service", "my-service"); + content.put("name", "fakeOperation"); + content.put("resource", "fakeResource"); + content.put("error", 0); + content.put("trace_id", 1L); + content.put("span_id", 1L); + content.put("parent_id", 0L); + content.put("start", 1000L); + content.put("duration", 10L); + content.put("meta", singletonMap(DDTags.DD_SVC_SRC, ServiceNameSources.MANUAL.toString())); + content.put("metrics", emptyMap()); + Map event = new TreeMap<>(); + event.put("type", "span"); + event.put("version", 1); + event.put("content", content); + Map expectedRequestBody = new TreeMap<>(); + expectedRequestBody.put("version", 1); + expectedRequestBody.put("metadata", metadata); + expectedRequestBody.put("events", singletonList(event)); + + runContentIsSentAsMsgpackTest( + TrackType.CITESTCYCLE, "v2", V2_EVP_PROXY_ENDPOINT, false, traces, expectedRequestBody); + } + + @Test + void testContentIsSentAsMsgpackTestSpan() throws IOException { + Map spanTags = new HashMap<>(); + spanTags.put("test_suite_id", 123L); + spanTags.put("test_module_id", 456L); + DDSpan span = buildSpan(1L, InternalSpanTypes.TEST, spanTags); + span.finish(); + setDurationNano(span, 10L); + List> traces = singletonList(singletonList(span)); + + Map metadata = buildMetadataMap(); + Map content = new TreeMap<>(); + content.put("test_suite_id", 123L); + content.put("test_module_id", 456L); + content.put("service", "fakeService"); + content.put("name", "fakeOperation"); + content.put("resource", "fakeResource"); + content.put("error", 0); + content.put("trace_id", 1L); + content.put("span_id", 1L); + content.put("parent_id", 0L); + content.put("start", 1000L); + content.put("duration", 10L); + content.put("meta", emptyMap()); + content.put("metrics", emptyMap()); + Map event = new TreeMap<>(); + event.put("type", "test"); + event.put("version", 2); + event.put("content", content); + Map expectedRequestBody = new TreeMap<>(); + expectedRequestBody.put("version", 1); + expectedRequestBody.put("metadata", metadata); + expectedRequestBody.put("events", singletonList(event)); + + runContentIsSentAsMsgpackTest( + TrackType.CITESTCYCLE, "v2", V2_EVP_PROXY_ENDPOINT, false, traces, expectedRequestBody); + } + + @Test + void testContentIsSentAsMsgpackTestSuiteEndSpan() throws IOException { + Map spanTags = new HashMap<>(); + spanTags.put("test_suite_id", 123L); + spanTags.put("test_module_id", 456L); + DDSpan span = buildSpan(1L, InternalSpanTypes.TEST_SUITE_END, spanTags); + span.finish(); + setDurationNano(span, 10L); + List> traces = singletonList(singletonList(span)); + + Map metadata = buildMetadataMap(); + Map content = new TreeMap<>(); + content.put("test_suite_id", 123L); + content.put("test_module_id", 456L); + content.put("service", "fakeService"); + content.put("name", "fakeOperation"); + content.put("resource", "fakeResource"); + content.put("error", 0); + content.put("start", 1000L); + content.put("duration", 10L); + content.put("meta", emptyMap()); + content.put("metrics", emptyMap()); + Map event = new TreeMap<>(); + event.put("type", "test_suite_end"); + event.put("version", 1); + event.put("content", content); + Map expectedRequestBody = new TreeMap<>(); + expectedRequestBody.put("version", 1); + expectedRequestBody.put("metadata", metadata); + expectedRequestBody.put("events", singletonList(event)); + + runContentIsSentAsMsgpackTest( + TrackType.CITESTCYCLE, "v2", V2_EVP_PROXY_ENDPOINT, false, traces, expectedRequestBody); + } + + @Test + void testContentIsSentAsMsgpackTestModuleEndSpanWithCompression() throws IOException { + Map spanTags = new HashMap<>(); + spanTags.put("test_module_id", 456L); + DDSpan span = buildSpan(1L, InternalSpanTypes.TEST_MODULE_END, spanTags); + span.finish(); + setDurationNano(span, 10L); + List> traces = singletonList(singletonList(span)); + + Map metadata = buildMetadataMap(); + Map content = new TreeMap<>(); + content.put("test_module_id", 456L); + content.put("service", "fakeService"); + content.put("name", "fakeOperation"); + content.put("resource", "fakeResource"); + content.put("error", 0); + content.put("start", 1000L); + content.put("duration", 10L); + content.put("meta", emptyMap()); + content.put("metrics", emptyMap()); + Map event = new TreeMap<>(); + event.put("type", "test_module_end"); + event.put("version", 1); + event.put("content", content); + Map expectedRequestBody = new TreeMap<>(); + expectedRequestBody.put("version", 1); + expectedRequestBody.put("metadata", metadata); + expectedRequestBody.put("events", singletonList(event)); + + runContentIsSentAsMsgpackTest( + TrackType.CITESTCYCLE, "v2", V4_EVP_PROXY_ENDPOINT, true, traces, expectedRequestBody); + } + + // --- Helper methods --- + + private void runContentIsSentAsMsgpackTest( + TrackType trackType, + String apiVersion, + String evpProxyEndpoint, + boolean compressionEnabled, + List> traces, + Map expectedRequestBody) + throws IOException { + String path = buildAgentEvpProxyPath(evpProxyEndpoint, trackType, apiVersion); + JavaTestHttpServer agentEvpProxy = + JavaTestHttpServer.httpServer( + s -> s.handlers(h -> h.post(path, api -> api.getResponse().send()))); + DDEvpProxyApi client = + createEvpProxyApi( + agentEvpProxy.getAddress().toString(), evpProxyEndpoint, trackType, compressionEnabled); + Payload payload = prepareTraces(trackType, compressionEnabled, traces); + + try { + client.sendSerializedTraces(payload).status(); + assertEquals("application/msgpack", agentEvpProxy.getLastRequest().getContentType()); + Map actualBody = + convertMap(agentEvpProxy.getLastRequest().getBody(), compressionEnabled); + assertDeepEquals(expectedRequestBody, actualBody); + } finally { + agentEvpProxy.close(); + } + } + + private static Map buildMetadataMap() { + Map star = new TreeMap<>(); + star.put("env", "my-env"); + star.put("runtime-id", "my-runtime-id"); + star.put("language", "my-language"); + star.put(Tags.RUNTIME_NAME, "my-runtime-name"); + star.put(Tags.RUNTIME_VERSION, "my-runtime-version"); + star.put(Tags.RUNTIME_VENDOR, "my-runtime-vendor"); + star.put(Tags.OS_ARCHITECTURE, "my-os-arch"); + star.put(Tags.OS_PLATFORM, "my-os-platform"); + star.put(Tags.OS_VERSION, "my-os-version"); + star.put(DDTags.TEST_IS_USER_PROVIDED_SERVICE, "false"); + Map metadata = new TreeMap<>(); + metadata.put("*", star); + return metadata; + } + + private static void setDurationNano(DDSpan span, long duration) { + try { + Field field = DDSpan.class.getDeclaredField("durationNano"); + field.setAccessible(true); + field.setLong(span, duration); + } catch (NoSuchFieldException | IllegalAccessException e) { + Assertions.fail("Could not set durationNano: " + e.getMessage()); + } + } + + static Map convertMap(byte[] bytes, boolean compressionEnabled) + throws IOException { + if (compressionEnabled) { + bytes = decompress(bytes); + } + return MSG_PACK_MAPPER.readValue(bytes, new TypeReference>() {}); + } + + static byte[] decompress(byte[] bytes) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (GZIPInputStream zip = new GZIPInputStream(new ByteArrayInputStream(bytes))) { + byte[] buf = new byte[4096]; + int len; + while ((len = zip.read(buf)) != -1) { + baos.write(buf, 0, len); + } + } + return baos.toByteArray(); + } + + private DDEvpProxyApi createEvpProxyApi( + String agentUrl, String evpProxyEndpoint, TrackType trackType, boolean compressionEnabled) { + return DDEvpProxyApi.builder() + .agentUrl(HttpUrl.get(agentUrl)) + .evpProxyEndpoint(evpProxyEndpoint) + .trackType(trackType) + .compressionEnabled(compressionEnabled) + .build(); + } + + private RemoteMapper discoverMapper(TrackType trackType, boolean compressionEnabled) { + DDIntakeMapperDiscovery mapperDiscovery = + new DDIntakeMapperDiscovery(trackType, WELL_KNOWN_TAGS, compressionEnabled); + mapperDiscovery.discover(); + return mapperDiscovery.getMapper(); + } + + private String buildAgentEvpProxyPath( + String evpProxyEndpoint, TrackType trackType, String apiVersion) { + return "/" + evpProxyEndpoint + "api/" + apiVersion + "/" + trackType.name().toLowerCase(); + } + + private Payload prepareTraces( + TrackType trackType, boolean compressionEnabled, List> traces) { + TracesCapture traceCapture = new TracesCapture(); + MsgPackWriter packer = new MsgPackWriter(new FlushingBuffer(1 << 20, traceCapture)); + RemoteMapper mapper = discoverMapper(trackType, compressionEnabled); + for (List trace : traces) { + packer.format(trace, mapper); + } + packer.flush(); + return mapper + .newPayload() + .withBody( + traceCapture.traceCount, + traces.isEmpty() ? ByteBuffer.allocate(0) : traceCapture.buffer); + } + + @SuppressWarnings("unchecked") + static void assertDeepEquals(Object expected, Object actual) { + if (expected == null && actual == null) { + return; + } + if (expected == null || actual == null) { + Assertions.fail("Expected " + expected + " but got " + actual); + } + if (expected instanceof Map) { + assertInstanceOf(Map.class, actual, "Expected Map but got " + actual.getClass()); + Map expectedMap = (Map) expected; + Map actualMap = (Map) actual; + assertEquals(expectedMap.size(), actualMap.size(), "Map size mismatch"); + for (Map.Entry entry : expectedMap.entrySet()) { + assertTrue(actualMap.containsKey(entry.getKey()), "Missing key: " + entry.getKey()); + assertDeepEquals(entry.getValue(), actualMap.get(entry.getKey())); + } + } else if (expected instanceof List) { + assertInstanceOf(List.class, actual, "Expected List but got " + actual.getClass()); + List expectedList = (List) expected; + List actualList = (List) actual; + assertEquals(expectedList.size(), actualList.size(), "List size mismatch"); + for (int i = 0; i < expectedList.size(); i++) { + assertDeepEquals(expectedList.get(i), actualList.get(i)); + } + } else if (expected instanceof Number && actual instanceof Number) { + if (expected instanceof Float + || expected instanceof Double + || actual instanceof Float + || actual instanceof Double) { + assertEquals(((Number) expected).doubleValue(), ((Number) actual).doubleValue(), 0.0001); + } else { + assertEquals(((Number) expected).longValue(), ((Number) actual).longValue()); + } + } else { + assertEquals(expected, actual); + } + } + + static class TracesCapture implements ByteBufferConsumer { + int traceCount; + ByteBuffer buffer; + + @Override + public void accept(int messageCount, ByteBuffer buffer) { + this.buffer = buffer; + this.traceCount = messageCount; + } + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/common/writer/ddintake/DDIntakeApiTest.java b/dd-trace-core/src/test/java/datadog/trace/common/writer/ddintake/DDIntakeApiTest.java new file mode 100644 index 00000000000..c00f322d9da --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/common/writer/ddintake/DDIntakeApiTest.java @@ -0,0 +1,464 @@ +package datadog.trace.common.writer.ddintake; + +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import datadog.communication.serialization.ByteBufferConsumer; +import datadog.communication.serialization.FlushingBuffer; +import datadog.communication.serialization.msgpack.MsgPackWriter; +import datadog.trace.agent.test.server.http.JavaTestHttpServer; +import datadog.trace.api.DDTags; +import datadog.trace.api.civisibility.CiVisibilityWellKnownTags; +import datadog.trace.api.intake.TrackType; +import datadog.trace.bootstrap.instrumentation.api.InternalSpanTypes; +import datadog.trace.bootstrap.instrumentation.api.ServiceNameSources; +import datadog.trace.bootstrap.instrumentation.api.Tags; +import datadog.trace.common.writer.Payload; +import datadog.trace.common.writer.RemoteApi; +import datadog.trace.common.writer.RemoteMapper; +import datadog.trace.core.DDCoreJavaSpecification; +import datadog.trace.core.DDSpan; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.OptionalInt; +import java.util.TreeMap; +import java.util.zip.GZIPInputStream; +import okhttp3.HttpUrl; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.msgpack.jackson.dataformat.MessagePackFactory; + +@Timeout(20) +class DDIntakeApiTest extends DDCoreJavaSpecification { + + static final CiVisibilityWellKnownTags WELL_KNOWN_TAGS = + new CiVisibilityWellKnownTags( + "my-runtime-id", + "my-env", + "my-language", + "my-runtime-name", + "my-runtime-version", + "my-runtime-vendor", + "my-os-arch", + "my-os-platform", + "my-os-version", + "false"); + + static final String API_KEY = "my-secret-apikey"; + static final ObjectMapper MSG_PACK_MAPPER = new ObjectMapper(new MessagePackFactory()); + + @Test + void testSendingAnEmptyListOfTracesReturnsNoErrors() { + TrackType trackType = TrackType.CITESTCYCLE; + String apiVersion = "v2"; + String path = buildIntakePath(trackType, apiVersion); + JavaTestHttpServer intake = + JavaTestHttpServer.httpServer( + s -> + s.handlers( + h -> + h.post( + path, + api -> { + if (!"application/msgpack" + .equals(api.getRequest().getContentType())) { + api.getResponse() + .status(400) + .send("wrong type: " + api.getRequest().getContentType()); + } else { + api.getResponse().status(200).send(); + } + }))); + DDIntakeApi client = createIntakeApi(intake.getAddress().toString(), trackType); + Payload payload = prepareTraces(trackType, Collections.emptyList()); + + try { + RemoteApi.Response clientResponse = client.sendSerializedTraces(payload); + assertTrue(clientResponse.success()); + assertTrue(clientResponse.status().isPresent()); + assertEquals(200, clientResponse.status().getAsInt()); + assertEquals(path, intake.getLastRequest().getPath()); + } finally { + intake.close(); + } + } + + @Test + void testRetriesWhenBackendReturns5xx() { + TrackType trackType = TrackType.CITESTCYCLE; + String apiVersion = "v2"; + String path = buildIntakePath(trackType, apiVersion); + int[] retry = {1}; + JavaTestHttpServer intake = + JavaTestHttpServer.httpServer( + s -> + s.handlers( + h -> + h.post( + path, + api -> { + if (retry[0] < 5) { + api.getResponse().status(503).send(); + retry[0]++; + } else { + api.getResponse().status(200).send(); + } + }))); + DDIntakeApi client = createIntakeApi(intake.getAddress().toString(), trackType); + Payload payload = prepareTraces(trackType, Collections.emptyList()); + + try { + RemoteApi.Response clientResponse = client.sendSerializedTraces(payload); + assertTrue(clientResponse.success()); + assertTrue(clientResponse.status().isPresent()); + assertEquals(200, clientResponse.status().getAsInt()); + assertEquals(path, intake.getLastRequest().getPath()); + } finally { + intake.close(); + } + } + + @Test + void testRetriesWhenBackendReturns429TooManyRequests() { + TrackType trackType = TrackType.CITESTCYCLE; + String apiVersion = "v2"; + String path = buildIntakePath(trackType, apiVersion); + int[] retry = {0}; + try (JavaTestHttpServer intake = + JavaTestHttpServer.httpServer( + s -> + s.handlers( + h -> + h.post( + path, + api -> { + if (retry[0] < 1) { + api.getResponse() + .status(429) + .addHeader("x-ratelimit-reset", "0") + .send(); + retry[0]++; + } else { + api.getResponse().status(200).send(); + } + })))) { + + DDIntakeApi client = createIntakeApi(intake.getAddress().toString(), trackType); + Payload payload = prepareTraces(trackType, Collections.emptyList()); + + RemoteApi.Response clientResponse = client.sendSerializedTraces(payload); + assertTrue(clientResponse.success()); + assertTrue(clientResponse.status().isPresent()); + assertEquals(200, clientResponse.status().getAsInt()); + assertEquals(path, intake.getLastRequest().getPath()); + } + } + + @Test + void testContentIsSentAsMsgpackEmptyTraces() throws IOException { + runContentIsSentAsMsgpackTest(TrackType.CITESTCYCLE, "v2", emptyList(), emptyMap()); + } + + @Test + void testContentIsSentAsMsgpackFakeTypeSpan() throws IOException { + DDSpan span = buildSpan(1L, "fakeType", Collections.singletonMap("service.name", "my-service")); + span.finish(); + setDurationNano(span, 10L); + List> traces = Collections.singletonList(Collections.singletonList(span)); + + Map metadata = buildMetadataMap(); + Map content = new TreeMap<>(); + content.put("service", "my-service"); + content.put("name", "fakeOperation"); + content.put("resource", "fakeResource"); + content.put("error", 0); + content.put("trace_id", 1L); + content.put("span_id", 1L); + content.put("parent_id", 0L); + content.put("start", 1000L); + content.put("duration", 10L); + content.put( + "meta", Collections.singletonMap(DDTags.DD_SVC_SRC, ServiceNameSources.MANUAL.toString())); + content.put("metrics", emptyMap()); + Map event = new TreeMap<>(); + event.put("type", "span"); + event.put("version", 1); + event.put("content", content); + Map expectedRequestBody = new TreeMap<>(); + expectedRequestBody.put("version", 1); + expectedRequestBody.put("metadata", metadata); + expectedRequestBody.put("events", Collections.singletonList(event)); + + runContentIsSentAsMsgpackTest(TrackType.CITESTCYCLE, "v2", traces, expectedRequestBody); + } + + @Test + void testContentIsSentAsMsgpackTestSpan() throws IOException { + Map spanTags = new HashMap<>(); + spanTags.put("test_suite_id", 123L); + spanTags.put("test_module_id", 456L); + DDSpan span = buildSpan(1L, InternalSpanTypes.TEST, spanTags); + span.finish(); + setDurationNano(span, 10L); + List> traces = Collections.singletonList(Collections.singletonList(span)); + + Map metadata = buildMetadataMap(); + Map content = new TreeMap<>(); + content.put("test_suite_id", 123L); + content.put("test_module_id", 456L); + content.put("service", "fakeService"); + content.put("name", "fakeOperation"); + content.put("resource", "fakeResource"); + content.put("error", 0); + content.put("trace_id", 1L); + content.put("span_id", 1L); + content.put("parent_id", 0L); + content.put("start", 1000L); + content.put("duration", 10L); + content.put("meta", emptyMap()); + content.put("metrics", emptyMap()); + Map event = new TreeMap<>(); + event.put("type", "test"); + event.put("version", 2); + event.put("content", content); + Map expectedRequestBody = new TreeMap<>(); + expectedRequestBody.put("version", 1); + expectedRequestBody.put("metadata", metadata); + expectedRequestBody.put("events", Collections.singletonList(event)); + + runContentIsSentAsMsgpackTest(TrackType.CITESTCYCLE, "v2", traces, expectedRequestBody); + } + + @Test + void testContentIsSentAsMsgpackTestSuiteEndSpan() throws IOException { + Map spanTags = new HashMap<>(); + spanTags.put("test_suite_id", 123L); + spanTags.put("test_module_id", 456L); + DDSpan span = buildSpan(1L, InternalSpanTypes.TEST_SUITE_END, spanTags); + span.finish(); + setDurationNano(span, 10L); + List> traces = Collections.singletonList(Collections.singletonList(span)); + + Map metadata = buildMetadataMap(); + Map content = new TreeMap<>(); + content.put("test_suite_id", 123L); + content.put("test_module_id", 456L); + content.put("service", "fakeService"); + content.put("name", "fakeOperation"); + content.put("resource", "fakeResource"); + content.put("error", 0); + content.put("start", 1000L); + content.put("duration", 10L); + content.put("meta", emptyMap()); + content.put("metrics", emptyMap()); + Map event = new TreeMap<>(); + event.put("type", "test_suite_end"); + event.put("version", 1); + event.put("content", content); + Map expectedRequestBody = new TreeMap<>(); + expectedRequestBody.put("version", 1); + expectedRequestBody.put("metadata", metadata); + expectedRequestBody.put("events", Collections.singletonList(event)); + + runContentIsSentAsMsgpackTest(TrackType.CITESTCYCLE, "v2", traces, expectedRequestBody); + } + + @Test + void testContentIsSentAsMsgpackTestModuleEndSpan() throws IOException { + DDSpan span = + buildSpan( + 1L, + InternalSpanTypes.TEST_MODULE_END, + Collections.singletonMap("test_module_id", 456L)); + span.finish(); + setDurationNano(span, 10L); + List> traces = Collections.singletonList(Collections.singletonList(span)); + + Map metadata = buildMetadataMap(); + Map content = new TreeMap<>(); + content.put("test_module_id", 456L); + content.put("service", "fakeService"); + content.put("name", "fakeOperation"); + content.put("resource", "fakeResource"); + content.put("error", 0); + content.put("start", 1000L); + content.put("duration", 10L); + content.put("meta", emptyMap()); + content.put("metrics", emptyMap()); + Map event = new TreeMap<>(); + event.put("type", "test_module_end"); + event.put("version", 1); + event.put("content", content); + Map expectedRequestBody = new TreeMap<>(); + expectedRequestBody.put("version", 1); + expectedRequestBody.put("metadata", metadata); + expectedRequestBody.put("events", Collections.singletonList(event)); + + runContentIsSentAsMsgpackTest(TrackType.CITESTCYCLE, "v2", traces, expectedRequestBody); + } + + // --- Helper methods --- + + private void runContentIsSentAsMsgpackTest( + TrackType trackType, + String apiVersion, + List> traces, + Map expectedRequestBody) + throws IOException { + String path = buildIntakePath(trackType, apiVersion); + JavaTestHttpServer intake = + JavaTestHttpServer.httpServer( + s -> s.handlers(h -> h.post(path, api -> api.getResponse().send()))); + DDIntakeApi client = createIntakeApi(intake.getAddress().toString(), trackType); + Payload payload = prepareTraces(trackType, traces); + + try { + OptionalInt status = client.sendSerializedTraces(payload).status(); + assertTrue(status.isPresent()); + assertEquals(200, status.getAsInt()); + assertEquals("application/msgpack", intake.getLastRequest().getContentType()); + Map actualBody = convertMap(intake.getLastRequest().getBody()); + assertDeepEquals(expectedRequestBody, actualBody); + } finally { + intake.close(); + } + } + + private static Map buildMetadataMap() { + Map star = new TreeMap<>(); + star.put("env", "my-env"); + star.put("runtime-id", "my-runtime-id"); + star.put("language", "my-language"); + star.put(Tags.RUNTIME_NAME, "my-runtime-name"); + star.put(Tags.RUNTIME_VERSION, "my-runtime-version"); + star.put(Tags.RUNTIME_VENDOR, "my-runtime-vendor"); + star.put(Tags.OS_ARCHITECTURE, "my-os-arch"); + star.put(Tags.OS_PLATFORM, "my-os-platform"); + star.put(Tags.OS_VERSION, "my-os-version"); + star.put(DDTags.TEST_IS_USER_PROVIDED_SERVICE, "false"); + Map metadata = new TreeMap<>(); + metadata.put("*", star); + return metadata; + } + + private static void setDurationNano(DDSpan span, long duration) { + try { + Field field = DDSpan.class.getDeclaredField("durationNano"); + field.setAccessible(true); + field.setLong(span, duration); + } catch (NoSuchFieldException | IllegalAccessException e) { + Assertions.fail("Could not set durationNano: " + e.getMessage()); + } + } + + static Map convertMap(byte[] bytes) throws IOException { + return MSG_PACK_MAPPER.readValue( + decompress(bytes), new TypeReference>() {}); + } + + static byte[] decompress(byte[] bytes) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (GZIPInputStream zip = new GZIPInputStream(new ByteArrayInputStream(bytes))) { + byte[] buf = new byte[4096]; + int len; + while ((len = zip.read(buf)) != -1) { + baos.write(buf, 0, len); + } + } + return baos.toByteArray(); + } + + private DDIntakeApi createIntakeApi(String url, TrackType trackType) { + HttpUrl hostUrl = HttpUrl.get(url); + return DDIntakeApi.builder().hostUrl(hostUrl).trackType(trackType).apiKey(API_KEY).build(); + } + + private RemoteMapper discoverMapper(TrackType trackType) { + DDIntakeMapperDiscovery mapperDiscovery = + new DDIntakeMapperDiscovery(trackType, WELL_KNOWN_TAGS, true); + mapperDiscovery.discover(); + return mapperDiscovery.getMapper(); + } + + private String buildIntakePath(TrackType trackType, String apiVersion) { + return String.format("/api/%s/%s", apiVersion, trackType.name().toLowerCase()); + } + + private Payload prepareTraces(TrackType trackType, List> traces) { + TracesCapture traceCapture = new TracesCapture(); + MsgPackWriter packer = new MsgPackWriter(new FlushingBuffer(1 << 20, traceCapture)); + RemoteMapper mapper = discoverMapper(trackType); + for (List trace : traces) { + packer.format(trace, mapper); + } + packer.flush(); + return mapper + .newPayload() + .withBody( + traceCapture.traceCount, + traces.isEmpty() ? ByteBuffer.allocate(0) : traceCapture.buffer); + } + + @SuppressWarnings("unchecked") + static void assertDeepEquals(Object expected, Object actual) { + if (expected == null && actual == null) { + return; + } + if (expected == null || actual == null) { + Assertions.fail("Expected " + expected + " but got " + actual); + } + if (expected instanceof Map) { + assertInstanceOf(Map.class, actual, "Expected Map but got " + actual.getClass()); + Map expectedMap = (Map) expected; + Map actualMap = (Map) actual; + assertEquals(expectedMap.size(), actualMap.size(), "Map size mismatch"); + for (Map.Entry entry : expectedMap.entrySet()) { + assertTrue(actualMap.containsKey(entry.getKey()), "Missing key: " + entry.getKey()); + assertDeepEquals(entry.getValue(), actualMap.get(entry.getKey())); + } + } else if (expected instanceof List) { + assertInstanceOf(List.class, actual, "Expected List but got " + actual.getClass()); + List expectedList = (List) expected; + List actualList = (List) actual; + assertEquals(expectedList.size(), actualList.size(), "List size mismatch"); + for (int i = 0; i < expectedList.size(); i++) { + assertDeepEquals(expectedList.get(i), actualList.get(i)); + } + } else if (expected instanceof Number && actual instanceof Number) { + if (expected instanceof Float + || expected instanceof Double + || actual instanceof Float + || actual instanceof Double) { + assertEquals(((Number) expected).doubleValue(), ((Number) actual).doubleValue(), 0.0001); + } else { + assertEquals(((Number) expected).longValue(), ((Number) actual).longValue()); + } + } else { + assertEquals(expected, actual); + } + } + + static class TracesCapture implements ByteBufferConsumer { + int traceCount; + ByteBuffer buffer; + + @Override + public void accept(int messageCount, ByteBuffer buffer) { + this.buffer = buffer; + this.traceCount = messageCount; + } + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/common/writer/ddintake/DDIntakeTraceInterceptorTest.java b/dd-trace-core/src/test/java/datadog/trace/common/writer/ddintake/DDIntakeTraceInterceptorTest.java new file mode 100644 index 00000000000..d757344634e --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/common/writer/ddintake/DDIntakeTraceInterceptorTest.java @@ -0,0 +1,101 @@ +package datadog.trace.common.writer.ddintake; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import datadog.trace.bootstrap.instrumentation.api.Tags; +import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; +import datadog.trace.common.writer.ListWriter; +import datadog.trace.core.CoreTracer; +import datadog.trace.core.DDCoreJavaSpecification; +import datadog.trace.core.DDSpan; +import java.util.List; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.tabletest.junit.TableTest; + +@Timeout(100) +class DDIntakeTraceInterceptorTest extends DDCoreJavaSpecification { + + ListWriter writer; + CoreTracer tracer; + + @BeforeEach + void setup() { + writer = new ListWriter(); + tracer = tracerBuilder().writer(writer).build(); + tracer.addTraceInterceptor(DDIntakeTraceInterceptor.INSTANCE); + } + + @AfterEach + void cleanup() { + if (tracer != null) { + tracer.close(); + } + } + + @TableTest({ + "scenario | httpStatus | expectedHttpStatus", + "null | | ", + "empty string | '' | ", + "string 500 | '500' | 500 ", + "integer 500 | 500 | 500 ", + "integer 600 | 600 | " + }) + void testNormalizationForDdIntake(Object httpStatus, Integer expectedHttpStatus) + throws InterruptedException, TimeoutException { + tracer + .buildSpan("datadog", "my-operation-name") + .withResourceName("my-resource-name") + .withSpanType("my-span-type") + .withServiceName("my-service-name") + .withTag("some-tag-key", "some-tag-value") + .withTag("env", " My_____Env ") + .withTag(Tags.HTTP_STATUS, httpStatus) + .start() + .finish(); + writer.waitForTraces(1); + + List trace = writer.firstTrace(); + assertEquals(1, trace.size()); + DDSpan span = trace.get(0); + assertEquals("my-service-name", span.getServiceName()); + assertEquals("my_operation_name", span.getOperationName().toString()); + assertEquals("my-resource-name", span.getResourceName().toString()); + assertEquals("my-span-type", span.getSpanType()); + assertEquals("some-tag-value", span.getTag("some-tag-key")); + assertEquals("my_env", span.getTag("env")); + assertEquals(expectedHttpStatus, span.getTag(Tags.HTTP_STATUS)); + } + + @Test + void testNormalizationDoesNotImplicitlyConvertSpanType() + throws InterruptedException, TimeoutException { + UTF8BytesString originalSpanType = UTF8BytesString.create("a UTF8 span type"); + tracer + .buildSpan("datadog", "my-operation-name") + .withSpanType(originalSpanType) + .start() + .finish(); + + writer.waitForTraces(1); + + List trace = writer.firstTrace(); + assertEquals(1, trace.size()); + DDSpan span = trace.get(0); + assertEquals(originalSpanType, span.getType()); + } + + @Test + void testDefaultEnvSetting() throws InterruptedException, TimeoutException { + tracer.buildSpan("datadog", "my-operation-name").start().finish(); + writer.waitForTraces(1); + + List trace = writer.firstTrace(); + assertEquals(1, trace.size()); + DDSpan span = trace.get(0); + assertEquals("none", span.getTag("env")); + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/common/writer/ddintake/DDIntakeTrackTypeResolverTest.java b/dd-trace-core/src/test/java/datadog/trace/common/writer/ddintake/DDIntakeTrackTypeResolverTest.java new file mode 100644 index 00000000000..5166a371966 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/common/writer/ddintake/DDIntakeTrackTypeResolverTest.java @@ -0,0 +1,30 @@ +package datadog.trace.common.writer.ddintake; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import datadog.trace.api.Config; +import datadog.trace.api.intake.TrackType; +import datadog.trace.test.util.DDJavaSpecification; +import org.tabletest.junit.TableTest; + +class DDIntakeTrackTypeResolverTest extends DDJavaSpecification { + + @TableTest({ + "scenario | ciVisibilityEnabled | ciVisibilityAgentlessEnabled | expectedTrackType", + "ci-vis disabled agentless disabled | false | false | NOOP ", + "ci-vis enabled agentless disabled | true | false | CITESTCYCLE ", + "ci-vis enabled agentless enabled | true | true | CITESTCYCLE " + }) + void shouldReturnTheCorrectTrackType( + boolean ciVisibilityEnabled, + boolean ciVisibilityAgentlessEnabled, + TrackType expectedTrackType) { + Config config = mock(Config.class); + when(config.isCiVisibilityEnabled()).thenReturn(ciVisibilityEnabled); + when(config.isCiVisibilityAgentlessEnabled()).thenReturn(ciVisibilityAgentlessEnabled); + + assertEquals(expectedTrackType, DDIntakeTrackTypeResolver.resolve(config)); + } +} diff --git a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/AbstractClassConstantConvertor.java b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/AbstractClassConstantConvertor.java index 122a9d9ad52..f68d4f813c0 100644 --- a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/AbstractClassConstantConvertor.java +++ b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/AbstractClassConstantConvertor.java @@ -32,9 +32,12 @@ public T convert(Object source, ParameterContext context) { s = s.substring(length + 1); } T mappedValue = mapping().get(s); - if (mappedValue == null && throwsOnUnsupportedValue()) { - throw new ArgumentConversionException( - "Unsupported constant " + source + " from " + className); + if (mappedValue == null) { + if (throwsOnUnsupportedValue()) { + throw new ArgumentConversionException( + "Unsupported constant " + source + " from " + className); + } + return convertWhenNoMapping(source); } return mappedValue; } @@ -43,6 +46,10 @@ protected boolean throwsOnUnsupportedValue() { return true; } + protected T convertWhenNoMapping(Object source) { + return null; + } + public abstract static class AbstractStringFallThruConverter extends AbstractClassConstantConvertor { @Override diff --git a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/SamplingMechanismConverter.java b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/SamplingMechanismConverter.java index 044edabead9..2b30a4e82dd 100644 --- a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/SamplingMechanismConverter.java +++ b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/SamplingMechanismConverter.java @@ -48,4 +48,14 @@ protected String className() { protected Map mapping() { return MAPPING; } + + @Override + protected boolean throwsOnUnsupportedValue() { + return false; + } + + @Override + protected Byte convertWhenNoMapping(Object source) { + return Byte.parseByte((String) source); + } } diff --git a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/tabletest/TableTestTypeConverters.java b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/tabletest/TableTestTypeConverters.java index 781ca972406..6f4157ab9fe 100644 --- a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/tabletest/TableTestTypeConverters.java +++ b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/tabletest/TableTestTypeConverters.java @@ -1,5 +1,7 @@ package datadog.trace.test.junit.utils.tabletest; +import datadog.trace.api.ProtocolVersion; +import datadog.trace.api.intake.TrackType; import org.tabletest.junit.TypeConverter; /** Shared converters for JUnit 5 TableTest tests that use unparsable constants. */ @@ -7,6 +9,30 @@ public final class TableTestTypeConverters { private TableTestTypeConverters() {} + @TypeConverter + public static TrackType toTrackType(String value) { + if (value == null) { + return null; + } + String token = value.trim(); + if (token.startsWith("TrackType.")) { + token = token.substring("TrackType.".length()); + } + return TrackType.valueOf(token); + } + + @TypeConverter + public static ProtocolVersion toProtocolVersion(String value) { + if (value == null) { + return null; + } + String token = value.trim(); + if (token.startsWith("ProtocolVersion.")) { + token = token.substring("ProtocolVersion.".length()); + } + return ProtocolVersion.valueOf(token); + } + @TypeConverter public static long toLong(String value) { if (value == null) {