Connections

A Connection is a UDP socket, a destination and a sample rate. Every client holds one, and every sub-client shares its parent’s, so an application normally ends up with one connection and many clients.

The defaults

Build a client without a connection and it makes one from the class-level defaults: localhost, port 8125, sample rate 1, enabled. Change them once at startup and everything built afterwards follows:

import statsd

statsd.Connection.set_defaults(
    host='localhost', port=8125, sample_rate=1, disabled=False
)

These are class attributes, so the call reaches clients created after it and leaves existing connections alone. Treat it as startup configuration, not a runtime switch.

Explicit connections

When one destination is not enough, build connections and hand them to the clients that need them:

import statsd

metrics = statsd.Connection(host='statsd-1', port=8125)
sampled = statsd.Connection(host='statsd-2', port=8125, sample_rate=0.1)

statsd.Counter('app.requests', metrics).increment()
statsd.Timer('app.render', sampled).start()

Sampling

A sample rate below 1 is a probability. The client rolls once per send, and a packet that does go out carries the rate so the server can multiply back up:

import statsd

connection = statsd.Connection(sample_rate=0.5)
statsd.Counter('app', connection).increment('requests')

That puts app.requests:1|c|@0.5 on the wire, and the server reads it as roughly two events. Sampling trades accuracy for packets. I turn it on when a counter is firing more than a few hundred times a second and leave it off everywhere else, because on a rare event it is not a trade at all: every packet dropped is an event nobody will ever see.

Here is the sharp edge. A falsy argument means “use the default”, not “use zero”:

import statsd

print(statsd.Connection(sample_rate=0))
print(statsd.Connection(sample_rate=0.5))

That prints <Connection[localhost:8125] P(1.0)> and then <Connection[localhost:8125] P(0.5)>. The zero became a 1, and the connection sends everything. The same rule applies to host, port and disabled, because each argument is read as value or default. To send nothing, disable the connection rather than reaching for a zero rate.

Turning it off

import statsd

connection = statsd.Connection(host='statsd-1', port=8125, disabled=True)
statsd.Counter('app.requests', connection).increment()

A disabled connection builds its socket and never writes to it. The client API is unchanged, so this is the switch for test suites and for environments with no statsd server, rather than wrapping call sites in conditions.

When things go wrong

Sends do not raise. An OSError from the socket is logged with a traceback and reported as a failed send, and everything else propagates, because a TypeError in your own metric name is a bug worth seeing rather than a packet worth dropping.

import statsd

connection = statsd.Connection(host='statsd.invalid')
counter = statsd.Counter('app', connection)
counter.increment('requests')

That runs cleanly on a machine where the host does not resolve. It is a deliberate trade: a metrics client that can take your application down with it is worse than no metrics.

Each connection logs to statsd.connection.Connection, at debug level for setup and for sends a disabled connection swallowed:

import logging

import statsd

logging.basicConfig(level=logging.DEBUG)
connection = statsd.Connection(disabled=True)
statsd.Counter('app.requests', connection).increment()

Turn that on when metrics are missing and you want to know whether the client sent anything at all. It answers the client half of the question, and the local stack page answers the network half.

Threads and forks

The socket is created once per connection and used with plain send() calls, which are atomic for datagrams of this size. Threads sharing a connection are fine: two threads sending at once produce two packets, not one interleaved mess.

Forking is the case to think about. A child process inherits the parent’s socket file descriptor, which works, but both processes then send through the same socket. That is harmless for UDP, where there is nothing to read and no ordering to corrupt. What bites is a pre-fork server that builds its connection before forking and then reconfigures it in the child: the descriptor is shared, so you are configuring both. Build connections after the fork, in the worker, and the question disappears.