Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/runtime-metrics-conventions.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: Runtime Metrics Conventions

on:
push:
branches:
- master
- release/**
pull_request:
paths:
- lib/sentry/metrics/**
- lib/sentry/metric.ex
- test/sentry/metrics/runtime_conventions_test.exs
- test/fixtures/sentry_conventions/**
- scripts/check_metrics_conventions.sh
- .github/workflows/runtime-metrics-conventions.yml
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:
inputs:
conventions_ref:
description: sentry-conventions branch, tag or commit to check against
required: false

env:
MIX_ENV: test
CONVENTIONS_REF: ${{ inputs.conventions_ref || 'feat/metric-model' }}

jobs:
conventions:
name: Runtime metrics match sentry-conventions
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- name: Check out this repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6

- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "24"

- name: Setup Elixir and Erlang
uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0
with:
elixir-version: "1.18"
otp-version: "27.2"

- name: Cache Elixir dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: |
deps
_build
key: ${{ runner.os }}-elixir-1.18-otp-27.2-mix-${{ hashFiles('**/mix*.lock') }}

- name: Download Mix dependencies
run: mix deps.get --check-locked

- name: Check runtime metrics against sentry-conventions
run: mix test.conventions
7 changes: 5 additions & 2 deletions lib/sentry/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -584,8 +584,11 @@ defmodule Sentry.Config do
type: :boolean,
default: false,
doc: """
Whether to attach `elixir_version` and `otp_release` attributes to every
reported measurement, so metrics can be grouped by runtime version. Off by
Whether to attach runtime version attributes to every reported
measurement, so metrics can be grouped by runtime version:
`process.runtime.name` (`"elixir"`), `process.runtime.version` (the
Elixir version), `process.runtime.engine.name` (`"BEAM"`) and
`process.runtime.engine.version` (the OTP release, such as `"27"`). Off by
default: the versions change only on upgrade, and attaching them to every
point starts a fresh series for each metric on every rolling deploy.
*Available since 14.0.0*.
Expand Down
59 changes: 45 additions & 14 deletions lib/sentry/metrics.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,31 @@ defmodule Sentry.Metrics do

# Record a counter
Sentry.Metrics.count("button.clicks", 1)
Sentry.Metrics.count("button.clicks", 5, unit: "click", attributes: %{button_id: "submit"})
Sentry.Metrics.count("button.clicks", 5, attributes: %{button_id: "submit"})

# Record a gauge
Sentry.Metrics.gauge("memory.usage", 1024, unit: "megabyte")

# Record a distribution
Sentry.Metrics.distribution("response.time", 42.5, unit: "millisecond")

## Units

The `:unit` option takes one of the units Sentry recognizes, so it can format and
convert values:

* Duration - `"nanosecond"`, `"microsecond"`, `"millisecond"`, `"second"`,
`"minute"`, `"hour"`, `"day"`, `"week"`
* Information - `"bit"`, `"byte"`, and the decimal and binary multiples of
`"byte"`: `"kilobyte"`, `"kibibyte"`, `"megabyte"`, `"mebibyte"`, `"gigabyte"`,
`"gibibyte"`, `"terabyte"`, `"tebibyte"`, `"petabyte"`, `"pebibyte"`,
`"exabyte"`, `"exbibyte"`
* Fraction - `"ratio"`, `"percent"`
* `"none"` - for plain counts and other values without a unit

Leave out `:unit` or pass `"none"` when you count things such as requests or
clicks. Describe what is counted in the metric name instead.

## Automatically Collected Metrics

The SDK can also report BEAM runtime health on its own, without any calls to the
Expand All @@ -37,15 +54,19 @@ defmodule Sentry.Metrics do

Once enabled, these gauges are reported:

* `elixir.runtime.mem.*` — `total`, `processes`, `processes_used`, `system`,
`atom`, `atom_used`, `binary`, `code` and `ets`, in bytes
* `elixir.runtime.run_queue.*` — `total`, `cpu` and `io`, how many processes are
waiting to run
* `elixir.runtime.process.*`, `elixir.runtime.atom.*` and `elixir.runtime.port.*` —
* `elixir.runtime.memory.used` - memory allocated by the VM, in bytes, reported
once per `elixir.memory.type` attribute value: `processes`, `atom`, `binary`,
`code`, `ets` and `other`. `other` is everything else the VM has allocated.
Summing across the types gives the VM total.
* `elixir.runtime.run_queue.length` - how many processes are waiting to run,
reported once per `elixir.run_queue.type` attribute value: `cpu` for the normal
and dirty CPU scheduler run queues and `io` for the dirty IO run queue. Summing
across the types gives the total run queue length.
* `elixir.runtime.process.*`, `elixir.runtime.atom.*` and `elixir.runtime.port.*` -
a `count`, the hard VM `limit`, and the `utilization` ratio between them. The
`limit` and `utilization` gauges need telemetry_poller 1.3.0 or later, which is
when it started measuring the limits; on older versions only `count` is reported.
* `elixir.runtime.scheduler.utilization` — the busy fraction of scheduler time, as a
* `elixir.runtime.scheduler.utilization` - the busy fraction of scheduler time, as a
ratio between `0.0` and `1.0`. Unlike the others this is a delta between two
samples, so the first collection only takes a baseline and the first value arrives
one collection later. The SDK polls for it itself, at the period configured for
Expand All @@ -67,9 +88,15 @@ defmodule Sentry.Metrics do

### Runtime Version Attributes

Setting `version_attributes: true` adds `elixir_version` and `otp_release`
attributes to every reported measurement, so metrics can be grouped by runtime
version. It is off by default, because those values change only on upgrade and
Setting `version_attributes: true` adds these attributes to every reported
measurement, so metrics can be grouped by runtime version:

* `process.runtime.name` - always `"elixir"`.
* `process.runtime.version` - the Elixir version, as returned by `System.version/0`.
* `process.runtime.engine.name` - always `"BEAM"`.
* `process.runtime.engine.version` - the OTP release, such as `"27"`.

It is off by default, because those values change only on upgrade and
attaching them to every point starts a fresh series for each metric on every
rolling deploy.

Expand All @@ -96,13 +123,15 @@ defmodule Sentry.Metrics do

## Options

* `:unit` - The unit of measurement (e.g., "click", "request"). Optional.
* `:unit` - The unit of measurement, one of those listed under
[Units](#module-units). Counters usually have none. Optional.
* `:attributes` - A map of key-value pairs to attach to the metric. Optional.

## Examples

Sentry.Metrics.count("button.clicks", 1)
Sentry.Metrics.count("http.requests", 5, unit: "request", attributes: %{method: "GET"})
Sentry.Metrics.count("http.requests", 5, attributes: %{method: "GET"})
Sentry.Metrics.count("http.response.body.size", 2048, unit: "byte")

"""
@spec count(String.t(), number(), keyword()) :: :ok
Expand All @@ -118,7 +147,8 @@ defmodule Sentry.Metrics do

## Options

* `:unit` - The unit of measurement (e.g., "byte", "connection"). Optional.
* `:unit` - The unit of measurement, one of those listed under
[Units](#module-units), such as `"byte"` or `"ratio"`. Optional.
* `:attributes` - A map of key-value pairs to attach to the metric. Optional.

## Examples
Expand All @@ -140,7 +170,8 @@ defmodule Sentry.Metrics do

## Options

* `:unit` - The unit of measurement (e.g., "millisecond", "byte"). Optional.
* `:unit` - The unit of measurement, one of those listed under
[Units](#module-units), such as `"millisecond"` or `"byte"`. Optional.
* `:attributes` - A map of key-value pairs to attach to the metric. Optional.

## Examples
Expand Down
62 changes: 39 additions & 23 deletions lib/sentry/metrics/runtime.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,15 @@ defmodule Sentry.Metrics.Runtime do

@events [@memory_event, @run_queue_event, @system_counts_event, @scheduler_event]

@run_queue_keys [:total, :cpu, :io]
@run_queue_types [:cpu, :io]

@system_counts [
{"process", :process_count, :process_limit},
{"atom", :atom_count, :atom_limit},
{"port", :port_count, :port_limit}
]

@memory_keys [
:total,
:processes,
:processes_used,
:system,
:atom,
:atom_used,
:binary,
:code,
:ets
]
@memory_types [:processes, :atom, :binary, :code, :ets]

@spec attach(keyword()) :: :ok
def attach(opts) when is_list(opts) do
Expand All @@ -59,11 +49,23 @@ defmodule Sentry.Metrics.Runtime do
:telemetry.handler_config()
) :: :ok
def handle_event(@memory_event, measurements, _metadata, config) do
report_measured(config, measurements, @memory_keys, "elixir.runtime.mem", "byte")
report_by_type(
config,
"elixir.runtime.memory.used",
"byte",
"elixir.memory.type",
memory_by_type(measurements)
)
end

def handle_event(@run_queue_event, measurements, _metadata, config) do
report_measured(config, measurements, @run_queue_keys, "elixir.runtime.run_queue", nil)
report_by_type(
config,
"elixir.runtime.run_queue.length",
"none",
"elixir.run_queue.type",
Map.take(measurements, @run_queue_types)
)
end

def handle_event(@system_counts_event, measurements, _metadata, config) do
Expand Down Expand Up @@ -139,23 +141,35 @@ defmodule Sentry.Metrics.Runtime do
defp report_count(_config, _name, nil, _limit), do: :ok

defp report_count(config, name, count, limit) do
gauge(config, "elixir.runtime.#{name}.count", count, nil)
gauge(config, "elixir.runtime.#{name}.count", count, "none")
report_limit(config, name, count, limit)
end

defp report_limit(_config, _name, _count, nil), do: :ok

defp report_limit(config, name, count, limit) do
gauge(config, "elixir.runtime.#{name}.limit", limit, nil)
gauge(config, "elixir.runtime.#{name}.limit", limit, "none")
gauge(config, "elixir.runtime.#{name}.utilization", ratio(count, limit), "ratio")
end

defp ratio(_count, 0), do: 0.0
defp ratio(count, limit), do: count / limit

defp report_measured(config, measurements, keys, prefix, unit) do
Enum.each(Map.take(measurements, keys), fn {key, value} ->
gauge(config, "#{prefix}.#{key}", value, unit)
defp memory_by_type(measurements) do
named = Map.take(measurements, @memory_types)

case measurements do
%{total: total} when map_size(named) == length(@memory_types) ->
Map.put(named, :other, max(total - Enum.sum(Map.values(named)), 0))

_incomplete ->
named
end
end

defp report_by_type(config, name, unit, type_attribute, values_by_type) do
Enum.each(values_by_type, fn {type, value} ->
gauge(config, name, value, unit, %{type_attribute => Atom.to_string(type)})
end)
end

Expand All @@ -173,12 +187,14 @@ defmodule Sentry.Metrics.Runtime do

defp version_attributes(true) do
%{
"elixir_version" => System.version(),
"otp_release" => List.to_string(:erlang.system_info(:otp_release))
"process.runtime.name" => "elixir",
"process.runtime.version" => System.version(),
"process.runtime.engine.name" => "BEAM",
"process.runtime.engine.version" => List.to_string(:erlang.system_info(:otp_release))
}
end

defp gauge(%{attributes: attributes}, name, value, unit) do
Metrics.gauge(name, value, unit: unit, attributes: attributes)
defp gauge(%{attributes: attributes}, name, value, unit, extra_attributes \\ %{}) do
Metrics.gauge(name, value, unit: unit, attributes: Map.merge(attributes, extra_attributes))
end
end
12 changes: 11 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -236,10 +236,20 @@ defmodule Sentry.Mixfile do
defp aliases do
[
test: ["sentry.package_source_code", "test"],
"test.integrations": &run_integration_tests_if_supported/1
"test.integrations": &run_integration_tests_if_supported/1,
"test.conventions": &run_conventions_check/1
]
end

defp run_conventions_check(args) do
{_, status} =
System.cmd(Path.expand("scripts/check_metrics_conventions.sh"), args,
into: IO.binstream(:stdio, :line)
)

if status > 0, do: System.at_exit(fn _ -> exit({:shutdown, status}) end)
end

defp run_integration_tests_if_supported(args) do
run_integration_tests("prod_mode", args, env: [{"MIX_ENV", "prod"}])

Expand Down
54 changes: 54 additions & 0 deletions scripts/check_metrics_conventions.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env bash

set -euo pipefail

repository="${CONVENTIONS_REPOSITORY:-getsentry/sentry-conventions}"
ref="${1:-${CONVENTIONS_REF:-feat/metric-model}}"
checkout="${CONVENTIONS_CHECKOUT:-tmp/sentry-conventions}"
definitions="test/fixtures/sentry_conventions/model"
minimum_node_major=22

cd "$(dirname "$0")/.."

for tool in git node; do
if ! command -v "$tool" >/dev/null; then
echo "$tool is required to check metrics against sentry-conventions" >&2
exit 1
fi
done

if [ ! -d "$checkout/.git" ]; then
echo "==> Cloning $repository into $checkout"
git clone --quiet --filter=blob:none "https://github.com/$repository.git" "$checkout"
fi

echo "==> Checking out $repository@$ref"
git -C "$checkout" fetch --quiet origin "$ref"
git -C "$checkout" checkout --quiet --force --detach FETCH_HEAD
git -C "$checkout" clean --quiet -fd -- model
echo "==> sentry-conventions at $(git -C "$checkout" rev-parse --short HEAD)"

node_runner=()
if [ "$(node -p 'process.versions.node.split(".")[0]')" -lt "$minimum_node_major" ]; then
pinned_node=$(node -p "require('./$checkout/package.json').volta?.node || '$minimum_node_major'")

if ! command -v mise >/dev/null; then
echo "sentry-conventions needs Node.js $minimum_node_major or later (it pins $pinned_node), found $(node --version)" >&2
exit 1
fi

echo "==> Using Node.js $pinned_node through mise, $(node --version) is too old for sentry-conventions"
node_runner=(mise exec "node@$pinned_node" --)
fi

echo "==> Validating $definitions with the sentry-conventions tests"
cp -R "$definitions/." "$checkout/model/"
(
cd "$checkout"
export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
"${node_runner[@]}" corepack yarn install --frozen-lockfile --silent
"${node_runner[@]}" corepack yarn vitest run test/metrics.test.ts test/attributes.test.ts
)

echo "==> Checking emitted runtime metrics against $definitions"
SENTRY_CONVENTIONS_PATH="$checkout" MIX_ENV=test mix test test/sentry/metrics/runtime_conventions_test.exs
Loading
Loading