Skip to content

[BUG]: v0.5 encoder emits an undecodable payload when the string table holds 65,536-131,071 entries #19515

Description

@d-loveen-c

Tracer Version(s)

4.8.6 (also present on main)

Python Version(s)

Python 3.13.14

Pip Version(s)

uv 0.11.28

Bug Report

array_prefix_size() in ddtrace/internal/_encoding.pyx uses 2<<16 (131072) where it means 1<<16 (65536):

cdef inline int array_prefix_size(stdint.uint32_t l):                                                                                                                                                                                                                                                      
    if l < 16:                                                                                                                                                                                                                                                                                             
        return 1                                                                                                                                                                                                                                                                                           
    elif l < (2<<16):          # 131072                                                                                                                                                                                                                                                                    
        return 3                                                                                                                                                                                                                                                                                           
    return MSGPACK_ARRAY_LENGTH_PREFIX_SIZE  # 5                                                                                                                                                                                                                                                           

For a string table holding between 65,536 and 131,071 entries the function returns 3 (the width of an array16 prefix), but msgpack_pack_array() correctly writes a 5-byte array32 header for those counts.

MsgpackStringTable.get_bytes() back-writes that header at an offset chosen so it ends exactly where the string data starts:

offset = MSGPACK_STRING_TABLE_LENGTH_PREFIX_SIZE - array_prefix_size(table_size)
self.pk.length = offset
ret = msgpack_pack_array(&self.pk, table_size)

With the width under-reported by 2, the header starts two bytes too late and overwrites the first two bytes of the table: the 0xa0 empty-string entry and the 0xaa fixstr marker of _dd.origin. Every subsequent byte is misaligned, so the payload cannot be decoded.

It is visible in the payload header. Same workload, three table sizes:

65,073 entries 92 dc fe 31 a0 aa 5f 64 64 2e 6f 72 ok
70,073 entries 92 dd 00 01 11 b9 5f 64 64 2e 6f 72 corrupt — a0 aa overwritten by the wider header
131,173 entries 92 dd 00 02 00 65 a0 aa 5f 64 64 2e ok

The decoder's first read after the header lands on 0x5f, the raw first byte of _dd.origin, and reports whatever msgpack marker that byte happens to encode.

Practical effect: the corrupted payload is rejected by the bundled libdatadog TraceExporter in AgentWriter._send_payload, so the trace is dropped in-process and never reaches the agent. The only symptom is a writer log line, and because the marker named in the message is just the first surviving
byte of the table it differs between payloads, which makes the failures look unrelated to each other:

failed to send, dropping 1 traces to intake at http://localhost:8126/v0.5/traces: Invalid type encountered: Type mismatch at marker FixExt8

Any single flush whose string table exceeds 65,535 entries loses its traces. Both tag keys and tag values are interned, so the threshold is reachable by any service that emits enough distinct tag keys and values in one payload. Above 131,072 entries the prefix width is correct again and payloads
encode fine, so the failure looks intermittent and load-dependent rather than deterministic.

MsgpackEncoderBase._update_array_len() uses the same helper for the traces array count and has the same defect, though it needs 65,536 or more traces in a single payload to trigger.

Suggested fix:

elif l < (1<<16):                                                                                                                                                                                                                                                                                          
    return 3

Reproduction Code

import os

os.environ["DD_TRACE_API_VERSION"] = "v0.5"

from ddtrace.internal.encoding import MsgpackEncoderV05
from ddtrace.internal.native import TraceExporterBuilder
from ddtrace.trace import tracer

TAGS_PER_SPAN = 50


def encode_payload(unique_tag_values):
    """Produce one v0.5 payload whose string table holds ~unique_tag_values entries."""
    spans = []
    for i in range(unique_tag_values // TAGS_PER_SPAN):
        span = tracer.trace("op", service="svc", resource="res")
        for t in range(TAGS_PER_SPAN):
            span.set_tag("k%d" % t, "v%08d" % (i * TAGS_PER_SPAN + t))
        span.finish()
        spans.append(span)

    encoder = MsgpackEncoderV05(64 << 20, 64 << 20)
    encoder.put(spans)
    payload, _count = encoder.encode()[0]
    return payload


def new_exporter():
    # Nothing is listening on port 1: the payload is deserialized before any connection is made,
    # so a NetworkError means the payload was valid and a DeserializationError means it was not.
    return (
        TraceExporterBuilder()
        .set_url("http://127.0.0.1:1")
        .set_hostname("h")
        .set_env("test")
        .set_app_version("0")
        .set_service("svc")
        .set_tracer_version("4.8.6")
        .set_language("python")
        .set_language_version("3.13")
        .set_language_interpreter("CPython")
        .set_input_format("v0.5")
        .set_output_format("v0.5")
        .build()
    )


def declared_entries(payload):
    if payload[1] == 0xDC:  # array16
        return int.from_bytes(payload[2:4], "big"), "array16"
    if payload[1] == 0xDD:  # array32
        return int.from_bytes(payload[2:6], "big"), "array32"
    raise AssertionError("unexpected string table marker 0x%02x" % payload[1])


for unique_tag_values in (65_000, 70_000, 131_100):
    payload = encode_payload(unique_tag_values)
    entries, header = declared_entries(payload)
    try:
        new_exporter().send(payload)
        outcome = "payload accepted"
    except Exception as exc:
        outcome = "%s: %s" % (type(exc).__name__, str(exc).splitlines()[0])

    print("string table entries : %d (%s header)" % (entries, header))
    print("first 12 bytes       : %s" % payload[:12].hex(" "))
    print("result               : %s\n" % outcome)

Error Logs

Output of the script above:

string table entries : 65073 (array16 header)
first 12 bytes : 92 dc fe 31 a0 aa 5f 64 64 2e 6f 72
result : NetworkError: client error (Connect)

string table entries : 70073 (array32 header)
first 12 bytes : 92 dd 00 01 11 b9 5f 64 64 2e 6f 72
result : DeserializationError: Invalid type encountered: Type mismatch at marker FixPos(95)

string table entries : 131173 (array32 header)
first 12 bytes : 92 dd 00 02 00 65 a0 aa 5f 64 64 2e
result : NetworkError: client error (Connect)

Setting DD_TRACE_API_VERSION=v0.4 avoids the problem, since v0.4 has no string table.

Libraries in Use

ddtrace==4.8.6

Operating System

Darwin 25.6.0 arm64

Operating System

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions