Testing support for a host application.
There are two ways to record with this library in a test, and the first one is the default:
- Owner-scoped. Configure
DdDataStreams.Testing.Backend, start it fromtest_helper.exs, and callstart/1in a test that wants checkpoints. Recording is per test, so tests can run withasync: true, and nothing has to be flushed or waited for. This is the API to reach for. - Global.
enable!/1andcapture/1flip the application environment, the shared aggregator and the VM-wide telemetry handler id. They are theasync: falsetools for tests that need the full wire-format path through the aggregator and the capture transport.
Owner-scoped tests
# config/test.exs
config :dd_data_streams, backend: DdDataStreams.Testing.Backend
# test/test_helper.exs
{:ok, _} = DdDataStreams.Testing.Backend.start_link()
defmodule MyApp.OrdersTest do
use ExUnit.Case, async: true
use DdDataStreams.Testing
test "the consume checkpoint links to the produce checkpoint" do
DdDataStreams.Testing.start()
DdDataStreams.consume(%{
data: payload,
metadata: %{
subscription: "projects/demo/subscriptions/orders-sub",
attributes: Map.new(DdDataStreams.produce("orders-out", payload))
}
})
produce = DdDataStreams.Testing.assert_produced("orders-out")
consume = DdDataStreams.Testing.assert_consumed("orders-sub")
DdDataStreams.Testing.assert_chained(produce, consume)
end
enduse DdDataStreams.Testing only picks private mode for an async case and
shared mode otherwise. A test opts in by calling start/1; a test that never
does sees produce/3 return [], records nothing, and can run alongside the
others. A process a test did not start, such as a Broadway processor, is
allowed with allow/2, by pid or by registered name. checkpoints/1 reads
the result, and subscribe/0 turns the same points into messages when a test
prefers assert_receive.
Global tests
enable!/1 and capture/1 keep their old meaning. They set :enabled and
the transport, start the shared aggregator and attach the shared handler id,
so a test using either runs with async: false. Use them when the test needs
what only that path gives: an encoded payload through the real aggregator,
or the wire-format bytes the agent would receive.
Summary
Functions
Sets up the owner-scoped mode from the ExUnit context.
Allows another process to record into owner.
Asserts that consume continues the pathway produce started.
Asserts that a consume checkpoint was recorded for subscription.
Asserts that a produce checkpoint was recorded for topic.
Global, async: false mode: runs fun, flushes every bucket, and returns
the points the transport captured while it ran.
The checkpoints recorded for owner (the calling process by default), oldest
first.
Decodes a captured transport body into a plain map.
Global, async: false mode: enables the library for one test and returns the
function that undoes it.
Picks set_private/0 for an async: true case and set_global/1 otherwise.
Switches the ownership server to shared mode: every process records into the calling test process.
Switches the ownership server to private mode.
Makes owner (the calling process by default) an owner of the checkpoints.
Subscribes the calling owner to checkpoint messages.
Functions
Sets up the owner-scoped mode from the ExUnit context.
Picks private mode when the test module is async: true and shared mode
otherwise, so a test module only needs:
use ExUnit.Case, async: true
use DdDataStreams.TestingIn private mode each test still opts in with start/1. In shared mode every
process records into the test process, so allow/2 is not needed. A module
whose context already chose a mode can call set_private/0 or set_global/1
itself instead.
Allows another process to record into owner.
Broadway processor and batcher processes are started by Broadway, not by the
test, so they are not in the test's $callers and need this call. Both
arguments accept a pid or a registered name:
DdDataStreams.Testing.start()
DdDataStreams.Testing.allow(self(), :"my_pipeline.Broadway.Processor_0")Returns :ok, or {:error, %NimbleOwnership.Error{}} when the ownership
server refuses the allowance (for example in shared mode, where every process
already records into the same owner).
Asserts that consume continues the pathway produce started.
produce and consume are points returned by assert_produced/2 and
assert_consumed/2, or read from checkpoints/1. Returns consume.
Asserts that a consume checkpoint was recorded for subscription.
Returns the point, like assert_produced/2.
Asserts that a produce checkpoint was recorded for topic.
Returns the point, so a test can chain more assertions on it:
produce = DdDataStreams.Testing.assert_produced("orders-out")
assert "type:google-pubsub" in produce.edge_tags
Global, async: false mode: runs fun, flushes every bucket, and returns
the points the transport captured while it ran.
What it flushes is the one shared aggregator, so a test using it must not run
next to another test that records. For the owner-scoped checkpoints a
pipeline records, use checkpoints/1 instead.
Attaches a handler of its own under a unique id to
[:dd_data_streams, :transport, :captured], so it sees exactly what is
flushed between the start of the call and the flush it makes itself, and
detaches it again whether fun returns or raises. The transport must report
on that event, which DdDataStreams.Transport.Capture does - pass it to
enable!/1 - and the aggregator must be running, or the flush has nothing
to send and the result is [].
The flush happens after fun returns, so a checkpoint recorded a moment
earlier is assertable without waiting for the ten-second bucket window, the
way DdDataStreams.Aggregator.flush(:all) sends it. Every point of every
payload flushed during the call is returned as one flat list of maps with
:edge_tags, :hash, :parent_hash, the three distributions and
:timestamp_type, in the shape decode_payload/1 gives each point.
The checkpoints recorded for owner (the calling process by default), oldest
first.
Each point is a plain map shaped like the points decode_payload/1 returns:
:edge_tags, :hash, :parent_hash, :pathway_latency, :edge_latency,
:payload_size and :timestamp_type. The latencies and the size are the
integers the checkpoint measured, not the flushed distributions, and
:timestamp_type is "current" because this is the checkpoint itself rather
than an aggregator bucket.
Reading is synchronous: record/1 in the backend has already stored the
point by the time the call that produced it returns, so no flush or wait is
needed. A test that never called start/1 gets []. In shared mode the
shared owner's points are returned.
Decodes a captured transport body into a plain map.
The body is the gzip-compressed MsgPack payload the agent would have
received, as [:dd_data_streams, :transport, :captured] reports it; it is
gunzipped and read back into the same shape the library builds it in: atom
keys in this library's vocabulary at every level.
iex> payload = %DdDataStreams.Payload{env: "prod", service: "checkout"}
iex> body = payload |> DdDataStreams.Payload.encode() |> IO.iodata_to_binary() |> :zlib.gzip()
iex> decoded = DdDataStreams.Testing.decode_payload(body)
iex> {decoded.env, decoded.service, decoded.stats}
{"prod", "checkout", []}A payload carries :env, :service, :stats, :tracer_version, :lang,
:version, :process_tags and :product_mask; each bucket in :stats
carries :start, :duration and its own :stats points, and each point
carries :edge_tags, :hash, :parent_hash, :pathway_latency,
:edge_latency, :payload_size and :timestamp_type. The three
distributions stay binaries, exactly as they went on the wire; they are
DdDataStreams.Sketch protos, not numbers. A key the payload vocabulary
does not know keeps the name it arrived with, so nothing is dropped.
It reads what DdDataStreams.Payload encodes; anything else raises.
@spec enable!(keyword()) :: (-> :ok)
Global, async: false mode: enables the library for one test and returns the
function that undoes it.
This is the aggregator-and-wire-format path. For tests that only want to see
the checkpoints a pipeline records, prefer the owner-scoped API (start/1
and checkpoints/1), which leaves the shared application environment and
aggregator alone and can run with async: true.
Sets enabled: true and any of the options :transport, :service and
:env: that are given, attaches the handlers DdDataStreams.attach/0
installs, and starts the aggregator if it is not already running, so
checkpoints recorded during the test are aggregated instead of dropped. Any
other option raises an ArgumentError, so a typo does not silently configure
nothing.
The returned zero-arity function stops the aggregator this call started,
detaches the handlers this call attached and restores the application
environment to what it was before the call; anything the call found already
running or already attached it leaves alone. It is safe to call twice, and
the aggregator is linked to the calling process, so a test that never runs
the cleanup still cannot leak one. Pass it to ExUnit.Callbacks.on_exit/1:
on_exit(DdDataStreams.Testing.enable!())
@spec set_from_context(map()) :: :ok
Picks set_private/0 for an async: true case and set_global/1 otherwise.
@spec set_global(map()) :: :ok
Switches the ownership server to shared mode: every process records into the calling test process.
Raises when the ExUnit context is async: true, because a shared owner would
receive another test's checkpoints.
@spec set_private(map()) :: :ok
Switches the ownership server to private mode.
This is the default mode and the one set_from_context/1 picks for an
async: true case.
Makes owner (the calling process by default) an owner of the checkpoints.
After this call, produce/3, produce_batch/3, consume/1, checkpoint/1
and the Broadway handlers record into owner, and checkpoints/1 reads what
they recorded. Calling it again is harmless. A Task records into the owner
that spawned it without this call, because it inherits $callers; any other
process needs allow/2.
Returns :ok. Raises when the ownership server is not running, because a
test that thinks it is recording and is not is worse than a failed test.
In shared mode the shared owner receives the checkpoints, so start/1 is not
needed there; calling it with the shared owner is harmless.
@spec subscribe() :: :ok
Subscribes the calling owner to checkpoint messages.
Every point recorded after this call is also sent to the owner as
{DdDataStreams.Testing, :checkpoint, point}, with the same map
checkpoints/1 returns. It is the push alternative for tests that prefer
assert_receive; checkpoints/1 is the default because it cannot lose a
point to a mailbox order.