sinal/fields
Builds the typed codecs that turn a Gleam value into a native telemetry map of measurements or metadata, and back.
Use this module to give a sinal.Event or a sinal/span span its
measurement and metadata types. string, int, float and bool
each declare one key; enum declares a key whose value is one of a
fixed set; optional makes a one-key field optional; field covers
any other value with an encoder and a gleam/dynamic/decode decoder.
A record codec is a use block: each include adds one field and
binds its value by name, and success builds the record. empty
declares no keys.
import sinal/fields
pub type Request {
Request(method: String, status: Int)
}
pub fn request_fields() -> fields.Fields(Request) {
use method <- fields.include(fields.string("method"), get: fn(r) {
r.method
})
use status <- fields.include(fields.int("status"), get: fn(r) { r.status })
fields.success(Request(method:, status:))
}
Each value goes to the constructor parameter it is bound to, so the
fields may be listed in any order and two fields of one type cannot
swap. Each getter is passed with its get: label and needs no type
annotation: the rest of the block, which ends in success, fixes the
record type first. This is the shape of json/blueprint/codec’s
field and success.
Keys are atoms
Each key becomes an atom of the native map, and the BEAM never frees an
atom. A key must match [a-z][a-z0-9_]{0,62} and must be written in
source code, never built from input. A key that breaks the grammar, a
key declared twice in one record, an optional field over anything but
one key, and an enum with no values or a repeated name are definition
bugs: the constructor panics with a message naming the key.
Decoding
Decoding reads only the declared keys and ignores any others in the map.
It fails with a FieldError when the term is not a map, a required key
is missing, or a value does not decode. Encoding has no typed error
return; custom getters and encoders can still raise. An enum value
missing from its list is reported when it is emitted, and check
returns the same error to a test (see enum).
Types
Why a native map did not decode. The union is closed: sinal adds no variant without a major release.
pub type FieldError {
NotAMap
MissingField(key: String)
InvalidField(key: String, errors: List(decode.DecodeError))
}
Constructors
-
NotAMapThe measurements or metadata term was not a map.
-
MissingField(key: String)A required key was absent.
-
InvalidField(key: String, errors: List(decode.DecodeError))The value at
keydid not decode;errorscome from its decoder.
Values
pub fn check(
fields: Fields(a),
value: a,
) -> Result(Nil, FieldError)
Reports an enum value missing from its declared list anywhere in
fields. It encodes, but a handler using these fields cannot decode it.
It returns the InvalidField error the handler would report, and
Ok(Nil) for any other value. The emit paths log a warning for the
same values; check only returns the error, so a test can assert it.
fields.check(method_field(), Delete)
// -> Error(InvalidField("method", [..])) when Delete is not listed
pub fn decode(
fields: Fields(a),
raw: dynamic.Dynamic,
) -> Result(a, FieldError)
Decodes a native map the way a handler would, reading only the declared keys.
pub fn describe_error(error: FieldError) -> String
Describes a decoding failure for logs.
pub fn empty() -> Fields(Nil)
Declares no keys. It decodes any map as Nil and rejects a term that is
not a map. It is the same codec as success(Nil).
pub fn encode(fields: Fields(a), value: a) -> dynamic.Dynamic
Encodes value into the native map that sinal.emit would send. Use it
to pin a package’s wire format in tests or to hand a map to Erlang code.
It writes an enum value missing from its list without complaint; the
emit paths report it.
pub fn enum(
key: String,
values: List(a),
name: fn(a) -> String,
) -> Fields(a)
Declares a key holding one of values, written as the UTF-8 binary
name(value). Decoding accepts that binary, or an atom with the same
text, and fails on any other name.
The compiler checks that name covers every constructor, but it cannot
check values: a constructor missing from the list compiles. Encoding
still writes its name, and every emit path (sinal.emit,
forwarder.emit, span.run) logs a warning in the emitting process
that names the event and the value. A handler using this enum
fails to decode it, reports MalformedMetadata (or
MalformedMeasurements) to its failure observer, skips that one
invocation and stays attached when failure reporting returns normally.
Those handlers skip the unlisted value until the list is fixed.
So keep values next to the type, and give the application a test that
names every constructor and round-trips it through the codec. The
compiler cannot list a type’s constructors, so the test names them
itself; when it names one that values lacks, check returns the error
and the decode fails:
pub fn every_method_is_listed_test() {
let codec = method_field()
// One entry per constructor of Method.
list.each([Get, Post], fn(method) {
let assert Ok(Nil) = fields.check(codec, method)
let assert Ok(decoded) =
fields.decode(codec, fields.encode(codec, method))
assert decoded == method
})
}
pub type Method {
Get
Post
}
fields.enum("method", [Get, Post], fn(method) {
case method {
Get -> "get"
Post -> "post"
}
})
Panics when values is empty or two values share a name.
pub fn field(
key: String,
encode: fn(a) -> dynamic.Dynamic,
decoder: decode.Decoder(a),
) -> Fields(a)
Declares one key whose value is written by encode and read by
decoder. Use it for a value the other constructors do not cover.
Inside a record, sinal runs decoder once against nil where the
record is defined, for a placeholder value, so keep it free of effects.
fields.field("attempt", dynamic.int, decode.int)
pub fn float(key: String) -> Fields(Float)
Declares a key holding a float. Decoding also accepts an integer, which native producers often send for a whole-number measurement, and converts it.
pub fn include(
field: Fields(a),
then next: fn(a) -> Fields(r),
get get: fn(r) -> a,
) -> Fields(r)
Adds one field to a record codec and binds its decoded value by name for
the rest of the use block, which ends in success:
use status <- fields.include(fields.int("status"), get: fn(r) { r.status })
field writes its keys from the value that get reads from the record.
It is usually one key, but it can be another record codec, whose keys
are written into the same map. The fields of a record may be listed in
any order: each value goes to the constructor parameter it is bound to.
Pass the getter with its get: label. Gleam checks arguments in
parameter order, and next (the rest of the use block) comes before
get, so the record type is known from success by the time the getter
is checked and the getter needs no annotation. Without the label the
getter fills the then slot and the call fails to compile.
The record’s keys and encoder are fixed once, where the record is
defined, by running the use block with placeholder values: the zero
value of each field’s decoder (fields.field runs its decoder against
nil for it). So keep the block to include calls and a success
constructor, and do not choose a field from a value bound before it.
Decoding runs the block again with the decoded values.
Panics when field declares a key the rest of the record already has.
pub fn keys(fields: Fields(a)) -> List(String)
The keys fields writes, in declaration order.
pub fn optional(inner: Fields(a)) -> Fields(option.Option(a))
Makes a one-key field optional. Encoding None omits the key.
Decoding reads a missing key, or the atom nil or undefined that a
native producer may write instead, as None; any other value decodes
through inner.
Do not wrap an inner whose own encoded values include the atoms nil
or undefined: they would decode as None.
Panics when inner declares other than exactly one key.
pub fn success(value: r) -> Fields(r)
Ends a record codec with the record built from the values that the
include calls before it bound:
pub fn request_fields() -> fields.Fields(Request) {
use method <- fields.include(fields.string("method"), get: fn(r) {
r.method
})
use status <- fields.include(fields.int("status"), get: fn(r) { r.status })
fields.success(Request(method:, status:))
}
Alone, success(value) declares no keys and decodes any map as value.