Data Streams Monitoring for Broadway pipelines.
Datadog's Data Streams Monitoring follows a payload from the service that produced it to the service that consumed it, so it can report how long the payload waited in the queue between them and how the services are connected. Doing that needs a checkpoint on each side of every edge and a pathway carried on the message itself. This module supplies the Broadway glue that decides where a checkpoint belongs; the pathway hashing, propagation, aggregation and transport live in the modules it calls.
Nothing here is specific to one broker, or to one broker per application. The
library hangs itself on the telemetry events Broadway already emits, so the
pathway and propagation layer works with any producer, and the parts that are
broker-specific - what a message's metadata says about the edge it arrived
on, and which technology to report - are modules behind
DdDataStreams.Extractor:
config :dd_data_streams, extractors: [MyApp.KafkaExtractor, DdDataStreams.Extractor.GooglePubSub]A message is named by the first configured extractor that recognises its
metadata, so an application can run a Kafka pipeline and a Pub/Sub pipeline
side by side. One extractor ships today -
DdDataStreams.Extractor.GooglePubSub - so tagging a message from any other
broker needs an extractor module of your own; the carriers Flat and Sqs
ship as well.
An extractor names the edge, not the service the checkpoint is reported under:
that is DdDataStreams.Config.service/0, unless a
DdDataStreams.ServiceName module is configured, which names it per message.
Using it in a Broadway pipeline
A Broadway pipeline reaches the library through telemetry instead of through a dependency, so attaching is the whole integration:
# at start up, before Broadway does:
DdDataStreams.attach()
# in handle_message/3 or handle_batch/4:
def handle_message(_processor, message, _context) do
data = encode(message.data)
attributes = existing_attributes
attributes = Enum.into(DdDataStreams.produce("orders-out", data), attributes)
MyApp.Publisher.publish("orders-out", data, attributes: attributes)
message
endproduce/3 records the produce checkpoint and returns the attributes to add
to the outgoing message - the pathway header and the topic - without
publishing. produce_batch/3 does the same for several payloads published to
one topic and returns the same attributes for the whole batch.
attach/0 installs two telemetry handlers:
[:broadway, :processor, :message, :start], which Broadway emits once for each message it is about to hand tohandle_message/3. The handler records the consume checkpoint - one per message that reacheshandle_message/3, including a leaf consumer that produces nothing - and leaves the resulting pathway inDdDataStreams.Contextfor a produce call in the same stage process.[:broadway, :batch_processor, :start], once per batch. The handler samples one of the pathways the batch's messages were consumed on and leaves that inDdDataStreams.Context, so a produce call inhandle_batch/4continues a pathway that connects to the messages being answered. Fan-in is a sample rather than a join, which is what dd-trace-go v2.11.2 does inMerge/MergeContexts: it keeps one pathway.
Both handlers record only what each event is: the batch handler adds no consume checkpoint, because every message of the batch already got one at its own message event.
A message that never reaches handle_message/3 therefore gets no consume
checkpoint. That happens when prepare_messages/2 raises: Broadway marks
every message of the batch failed before the per-message start event
(deps/broadway/lib/broadway/topology/processor_stage.ex, the
maybe_prepare_messages/2 wrapper), so the start event never fires and a
raise silences the checkpoint for the whole batch, not just the message that
raised. Marking a message failed with Broadway.Message.failed/2 is a
different case: those messages still reach handle_message/3, so they still
checkpoint, and failed messages take Broadway's failed-ack path either way.
A redelivery is a new attempt and counts a new checkpoint; the wait is still
measured from the receive time, so per-attempt counting does not distort it.
Messages the library cannot record
The handlers never raise. Telemetry detaches a handler that raises, for every
pipeline in the VM, and never reattaches it, so a single malformed message
would stop monitoring for the whole application. A message the library cannot
record is skipped instead: the pathway and the extractor of the previous
message are cleared, so a later produce does not continue them, and the reason
is logged and reported as a [:dd_data_streams, :handler, :error] telemetry
event.
Pathways that are not a Broadway stage
consume/1 and produce/3 are the same two checkpoints as functions, for a
pipeline that drives the library itself, and checkpoint/2 records one for a
stage that is neither a consume nor a produce.
Checkpoints are handed to the configured DdDataStreams.Backend, which drops
them when the calling process is not recording (the default backend delegates
to DdDataStreams.Aggregator.add/1, which does nothing when the aggregator
is not running), so none of these needs to be guarded at the call site.
Summary
Types
The metadata key and base64 pathway value to add to an outgoing message, as
returned by produce/3 and produce_batch/3.
Functions
Installs the Broadway telemetry handlers that record consume checkpoints and fan-in pathways.
Records a checkpoint for the stage reached by tags and returns the
checkpointed pathway. The checkpoint has already been given to the
aggregator, so it is not returned; use Pathway.set_checkpoint/3 if you
need the measurement itself.
Records the consume checkpoint of a message, and leaves the checkpointed
pathway in DdDataStreams.Context.
Removes the telemetry handlers attach/0 installed, so recording stops
without restarting the application.
Records the produce checkpoint for data published to topic and returns the
{key, value} pairs to add to the outgoing message's attributes or headers.
Records one produce checkpoint per payload in datas and returns the
{key, value} pairs for the whole batch.
The library's effective configuration and liveness, as one map, answered from the local VM: no Datadog Agent, no aggregator flush, nothing that can block.
Types
The metadata key and base64 pathway value to add to an outgoing message, as
returned by produce/3 and produce_batch/3.
The key is DdDataStreams.Config.header/0, "dd-pathway-ctx-base64" unless
configured; the value is the encoded pathway.
Functions
@spec attach() :: :ok
Installs the Broadway telemetry handlers that record consume checkpoints and fan-in pathways.
Call it once, when the application starts, before Broadway starts. It does
nothing when the library is disabled, and calling it again is harmless:
telemetry keeps one handler per id and the second call returns :ok instead
of failing, so a hot code upgrade does not double the checkpoints.
Once installed, a handler records only when the configured
DdDataStreams.Backend says the emitting process is recording, so a test
suite can attach once at boot and leave the handlers attached: a test that
did not opt in emits events the handlers turn into no-ops. Only tests that
attach or detach themselves, or that use the global aggregator testing API,
still need async: false.
Which broker a message comes from, and which edge tags it is reported under,
is the configured DdDataStreams.Extractor's business. Attaching fails with an
ArgumentError when config :dd_data_streams, extractors: is not a non-empty
list of extractors, rather than leaving a handler attached that cannot name an
edge. The extractors are validated even while the library is disabled, so a
configuration mistake raises when the application starts instead of waiting
for the flag to be flipped.
@spec checkpoint([String.t()]) :: DdDataStreams.Pathway.t()
Records a checkpoint for the stage reached by tags and returns the
checkpointed pathway. The checkpoint has already been given to the
aggregator, so it is not returned; use Pathway.set_checkpoint/3 if you
need the measurement itself.
checkpoint/1 defaults pathway to DdDataStreams.Context.get/0 (or a new
pathway when there is none); checkpoint/2 takes it explicitly, for a stage
that wants to continue a pathway it already holds. tags are the edge tags
reported for this stage, such as ["direction:in", "type:my-queue"].
DdDataStreams.Tags
decides what each key does with them: some are hashed into the node, and
others, such as group: or manual_checkpoint:true, split one node's
statistics without splitting its identity. A key the vocabulary does not know
is reported on the edge and does neither.
The checkpoint is stamped with the current time, has no payload size, and is given to the aggregator. The caller's context is left alone: this is a checkpoint in the middle of a pathway, not a new stage to produce from.
@spec checkpoint(DdDataStreams.Pathway.t(), [String.t()]) :: DdDataStreams.Pathway.t()
@spec consume(map()) :: DdDataStreams.Pathway.t() | nil
Records the consume checkpoint of a message, and leaves the checkpointed
pathway in DdDataStreams.Context.
message is a Broadway message, or a map shaped like one: the body in
:data and the producer's own metadata map in :metadata. The configured
extractor names the edge from that metadata and hands back the parts a
checkpoint measures, so a pipeline that drives the library itself only has to
build the metadata the producer would have built.
Returns nil when the extractor finds no edge in the metadata - a message
with no subscription, no topic, nothing to report it under - and records
nothing in that case; the miss is reported as a
[:dd_data_streams, :checkpoint, :skip] telemetry event whose :extractors
metadata is the configured list that was asked. It also clears the process
context, so a later
produce/3 cannot chain onto the previous message's pathway. An extractor
that raises is treated the same way, and
the reason is logged and reported as a [:dd_data_streams, :handler, :error]
telemetry event. Otherwise returns the pathway the checkpoint reached. The
checkpoint has already been given to the aggregator, so it is not returned;
use DdDataStreams.Checkpoints.consume/3 if you need the checkpoint to
record or forward it yourself, and do not add it to this aggregator again.
@spec detach() :: :ok
Removes the telemetry handlers attach/0 installed, so recording stops
without restarting the application.
Broadway events are no longer recorded afterwards; consume/1, produce/3
and checkpoint/1 keep working as plain functions while the library is
enabled, and checkpoints already handed to the aggregator are still flushed.
Calling it when nothing is attached returns :ok too, so a test can end with
it unconditionally, and attach/0 and detach/0 may be interleaved freely.
@spec produce(String.t(), term(), keyword()) :: [propagation()]
Records the produce checkpoint for data published to topic and returns the
{key, value} pairs to add to the outgoing message's attributes or headers.
It does not publish. The caller merges the pairs into the same metadata the receiver will read:
attributes = Enum.into(DdDataStreams.produce("orders-out", data), existing_attributes)
MyApp.PubSub.publish("orders-out", data, attributes: attributes)The pathway key is DdDataStreams.Config.header/0, "dd-pathway-ctx-base64"
unless configured, and the topic is written under
DdDataStreams.Propagator.topic_header/0, "dd-topic". The topic pair is
only returned when the outgoing extractor names a topic, so a consumer can
report the topic its metadata does not carry. The pathway continued is the one
in DdDataStreams.Context, so this is meant to be called from the stage that
consumed the message being answered: inside handle_message/3 it chains the
produce onto that message's consume checkpoint, inside handle_batch/4 onto
the batch's fan-in pathway. With no context the produce starts a new pathway.
This function does not change the context.
data is the outgoing payload. It is only measured, never inspected: a binary
is measured with byte_size/1, and anything else counts as zero. The size of
the pairs this function returns is added to it, because they travel with the
message and the consumer measures every attribute it receives: measure the
message without the pairs. Pass payload_size: when the payload's size is
known another way, or when the message's own attributes should be counted.
Call it immediately before sending: the checkpoint is recorded when this function runs, so a send that never happens still reports a produce. Producing several messages from one consumed message means calling this once per message, not chaining one call's result into the next, so all of them share one produce node.
The edge tags come from the extractor that named the consume edge of the
message being answered, which is the one attach/0 left in the context, so a
pipeline that consumes and produces on the same technology needs nothing more.
That extractor also receives the producer info of the message being answered,
so identity kept in the producer's configuration - a Kafka cluster id, a
RabbitMQ exchange - can be reported on the produce edge; a stage that
produces without consuming passes both fields nil. A pipeline that changes
technology - consuming from Kafka and publishing to
Pub/Sub - names the outgoing one, because the topic alone does not say which
bus it goes to:
DdDataStreams.produce("orders-out", data, extractor: MyApp.PubSubExtractor)Options:
:extractor- the extractor that names the outgoing edge. Defaults to the extractor that consumed the message being answered, then to the only configured one; a stage that produces without consuming and has several extractors must pass it.:payload_size- the size in bytes to report, a non-negative integer. Defaults to the size ofdata. The pairs this function returns are added to it, so pass the size of the message without those pairs.:service- the service to report this produce under, overriding the configuredDdDataStreams.ServiceNameandDdDataStreams.Config.service/0. A consume in the same stage does not inherit it: each checkpoint asks for its own name.
Any other option is passed to an extractor that implements the optional
DdDataStreams.Extractor.produce/3, which is how an edge that needs more
than a topic is named - a RabbitMQ publish names its exchange and routing key.
The library neither reads nor validates those options, and an extractor that
implements only produce/2 never sees them.
Returns [] when the library is disabled, and records nothing.
@spec produce_batch(String.t(), Enumerable.t(), keyword()) :: [propagation()]
Records one produce checkpoint per payload in datas and returns the
{key, value} pairs for the whole batch.
All the payloads are published to the same topic; the pairs are the same for
every message because the batch shares one produce node. Add them to each
message's metadata:
attributes = Enum.into(DdDataStreams.produce_batch("orders-out", payloads), existing_attributes)
MyApp.PubSub.publish_batch("orders-out", payloads, attributes: attributes)Each payload records its own checkpoint with its own size, but all of them
have the same hash and timestamp, so the batch is one produce edge. datas
is any Enumerable; it is materialised once. An empty enumerable returns
[]; a disabled library returns [].
Payload size is byte_size/1 per binary payload, or zero otherwise, plus the
size of the pairs this function returns, as produce/3 does. There is no
:payload_size option here: call produce/3 once per message when each size
needs to be measured differently.
The :extractor option is the same as for produce/3, as is any option an
extractor implementing DdDataStreams.Extractor.produce/3 reads. :service
names the whole batch, since the batch is one produce node. Call it
immediately before the batch send, for the same reason as produce/3.
@spec status() :: %{ enabled: boolean(), attached: boolean(), aggregator_running: boolean(), transport: module() | nil, agent_url: String.t(), header: String.t(), service: String.t(), env: String.t(), service_name: module() | nil, extractors: [module()] }
The library's effective configuration and liveness, as one map, answered from the local VM: no Datadog Agent, no aggregator flush, nothing that can block.
:enabled- the configured flag.attach/0reads it, and so does the defaultDdDataStreams.Backend.Aggregator; a configured backend answers whether a particular process records for itself.:attached- whether the Broadway telemetry handlers are installed.:aggregator_running- whether the process that batches checkpoints and posts them is running.:transport,:agent_url,:header,:service,:env,:service_name,:extractors- the effective values of the same settings the recording itself reads, as of this call.
"Is it recording?" is all three of :enabled, :attached and
:aggregator_running for the default backend: the flag decides whether new
checkpoints are made, the handlers decide whether Broadway events make any,
and the aggregator decides whether what they make is ever posted. With a
configured backend, ask that backend whether the calling process records.