Current section

Files

Jump to
caffeine_lang src caffeine_lang codegen honeycomb.gleam
Raw

src/caffeine_lang/codegen/honeycomb.gleam

import caffeine_lang/codegen/generator_utils
import caffeine_lang/constants
import caffeine_lang/errors.{type CompilationError}
import caffeine_lang/helpers
import caffeine_lang/linker/ir.{type IntermediateRepresentation, type Resolved}
import gleam/dict
import gleam/list
import gleam/option
import gleam/result
import gleam/string
import terra_madre/common
import terra_madre/hcl
import terra_madre/terraform
/// Generate Terraform HCL from a list of Honeycomb IntermediateRepresentations.
pub fn generate_terraform(
irs: List(IntermediateRepresentation(Resolved)),
) -> Result(String, CompilationError) {
generator_utils.generate_terraform(
irs,
settings: terraform_settings(),
provider: provider(),
variables: variables(),
generate_resources: generate_resources,
)
}
/// Generate only the Terraform resources for Honeycomb IRs (no config/provider).
@internal
pub fn generate_resources(
irs: List(IntermediateRepresentation(Resolved)),
) -> Result(#(List(terraform.Resource), List(String)), CompilationError) {
generator_utils.generate_resources_multi(
irs,
mapper: ir_to_terraform_resources,
)
}
/// Terraform settings block with required Honeycomb provider.
@internal
pub fn terraform_settings() -> terraform.TerraformSettings {
generator_utils.build_terraform_settings(
provider_name: constants.provider_honeycombio,
source: "honeycombio/honeycombio",
version: "~> 0.31",
)
}
/// Honeycomb provider configuration using variables for credentials.
@internal
pub fn provider() -> terraform.Provider {
generator_utils.build_provider(
name: constants.provider_honeycombio,
attributes: [#("api_key", hcl.ref("var.honeycomb_api_key"))],
)
}
/// Variables for Honeycomb API credentials and dataset.
@internal
pub fn variables() -> List(terraform.Variable) {
[
terraform.Variable(
name: "honeycomb_api_key",
type_constraint: option.Some(hcl.Identifier("string")),
default: option.None,
description: option.Some("Honeycomb API key"),
sensitive: option.Some(True),
nullable: option.None,
validation: [],
),
terraform.Variable(
name: "honeycomb_dataset",
type_constraint: option.Some(hcl.Identifier("string")),
default: option.None,
description: option.Some("Honeycomb dataset slug"),
sensitive: option.None,
nullable: option.None,
validation: [],
),
]
}
/// Convert a single IntermediateRepresentation to Honeycomb Terraform Resources.
/// Produces a derived_column for the SLI and an SLO that references it.
@internal
pub fn ir_to_terraform_resources(
ir: IntermediateRepresentation(Resolved),
) -> Result(List(terraform.Resource), CompilationError) {
let resource_name = common.sanitize_terraform_identifier(ir.unique_identifier)
// Extract structured SLO fields from IR.
use slo <- result.try(generator_utils.require_slo_fields(
ir,
vendor: constants.vendor_honeycomb,
))
let threshold = slo.threshold
let window_in_days = slo.window_in_days
let indicators = slo.indicators
// Extract the evaluation expression, then resolve it through the CQL pipeline
// by substituting indicator names into the evaluation formula.
use evaluation_expr <- result.try(generator_utils.require_evaluation(
slo,
ir,
vendor: constants.vendor_honeycomb,
))
use sli_expression <- result.try(generator_utils.resolve_cql_expression(
evaluation_expr,
indicators,
ir,
vendor: constants.vendor_honeycomb,
))
let time_period = window_to_time_period(window_in_days)
let derived_column_alias = resource_name <> "_sli"
// Resource 1: honeycombio_derived_column for the SLI.
let derived_column =
terraform.Resource(
type_: "honeycombio_derived_column",
name: derived_column_alias,
attributes: dict.from_list([
#("alias", hcl.StringLiteral(derived_column_alias)),
#("expression", hcl.StringLiteral(sli_expression)),
#("dataset", hcl.ref("var.honeycomb_dataset")),
]),
blocks: [],
meta: hcl.empty_meta(),
lifecycle: option.None,
)
// Resource 2: honeycombio_slo.
let slo_description = generator_utils.build_description(ir, with: slo)
let slo_attributes = [
#("name", hcl.StringLiteral(ir.metadata.friendly_label.value)),
#("description", hcl.StringLiteral(slo_description)),
#("dataset", hcl.ref("var.honeycomb_dataset")),
#(
"sli",
hcl.ref("honeycombio_derived_column." <> derived_column_alias <> ".alias"),
),
#("target_percentage", hcl.FloatLiteral(threshold)),
#("time_period", hcl.IntLiteral(time_period)),
#("tags", build_tags(ir)),
]
let slo =
terraform.Resource(
type_: "honeycombio_slo",
name: resource_name,
attributes: dict.from_list(slo_attributes),
blocks: [],
meta: hcl.empty_meta(),
lifecycle: option.None,
)
Ok([derived_column, slo])
}
/// Build tags as a map expression for Honeycomb.
/// Honeycomb tags: keys ^[a-z]{1,32}$, values ^[a-z][a-z0-9/-]{1,128}$.
/// Max 10 tags per resource.
fn build_tags(ir: IntermediateRepresentation(Resolved)) -> hcl.Expr {
// Build system tags from shared helper. For Honeycomb, misc tags with multiple
// values are joined with forward slashes since commas are not valid in tag values.
let system_tag_pairs =
helpers.build_system_tag_pairs(
org_name: ir.metadata.org_name,
team_name: ir.metadata.team_name,
service_name: ir.metadata.service_name,
blueprint_name: ir.metadata.blueprint_name,
friendly_label: ir.metadata.friendly_label,
artifact_refs: [],
misc: ir.metadata.misc,
)
|> collapse_multi_value_tags
// Build user-provided tags from structured artifact data.
let user_tag_pairs = case ir.get_slo_fields(ir.artifact_data) {
option.Some(slo) -> slo.tags
option.None -> []
}
let all_tags =
list.append(system_tag_pairs, user_tag_pairs)
|> list.map(fn(pair) {
#(
hcl.IdentKey(sanitize_honeycomb_tag_key(pair.0)),
hcl.StringLiteral(sanitize_honeycomb_tag_value(pair.1)),
)
})
|> list.take(max_tags_per_resource)
hcl.MapExpr(all_tags)
}
const max_tags_per_resource = 10
/// Sanitize a tag key for Honeycomb: must match ^[a-z]{1,32}$.
/// Removes underscores, hyphens, digits, and any non-letter characters.
@internal
pub fn sanitize_honeycomb_tag_key(key: String) -> String {
key
|> string.lowercase
|> string.to_graphemes
|> list.filter(is_lowercase_letter)
|> string.concat
|> string.slice(0, 32)
}
/// Sanitize a tag value for Honeycomb: must match ^[a-z][a-z0-9/-]{1,128}$.
/// Spaces, underscores, and commas become hyphens; uppercase becomes lowercase;
/// values starting with a non-letter get a "v" prefix.
@internal
pub fn sanitize_honeycomb_tag_value(value: String) -> String {
let sanitized =
value
|> string.lowercase
|> string.to_graphemes
|> list.map(fn(char) {
case char {
" " | "_" | "," -> "-"
_ ->
case is_valid_tag_value_char(char) {
True -> char
False -> ""
}
}
})
|> string.concat
// Ensure value starts with a lowercase letter.
let sanitized = case string.first(sanitized) {
Ok(c) ->
case is_lowercase_letter(c) {
True -> sanitized
False -> "v" <> sanitized
}
_ -> sanitized
}
sanitized
|> string.slice(0, 129)
}
fn is_lowercase_letter(char: String) -> Bool {
case string.to_utf_codepoints(char) {
[cp] -> {
let code = string.utf_codepoint_to_int(cp)
code >= 97 && code <= 122
}
_ -> False
}
}
fn is_valid_tag_value_char(char: String) -> Bool {
case char {
"-" | "/" -> True
_ -> is_lowercase_letter(char) || is_digit(char)
}
}
fn is_digit(char: String) -> Bool {
case string.to_utf_codepoints(char) {
[cp] -> {
let code = string.utf_codepoint_to_int(cp)
code >= 48 && code <= 57
}
_ -> False
}
}
/// Collapse tag pairs that share the same key by joining values with forward slashes.
/// The shared helper produces one pair per misc value, but Honeycomb needs a
/// single key-value entry per tag key. Preserves insertion order of first occurrence.
fn collapse_multi_value_tags(
pairs: List(#(String, String)),
) -> List(#(String, String)) {
let #(order, merged) =
pairs
|> list.fold(#([], dict.new()), fn(acc, pair) {
let #(keys, seen) = acc
let #(key, value) = pair
case dict.get(seen, key) {
Ok(existing) -> #(
keys,
dict.insert(seen, key, existing <> "/" <> value),
)
Error(_) -> #([key, ..keys], dict.insert(seen, key, value))
}
})
order
|> list.reverse
|> list.filter_map(fn(key) {
dict.get(merged, key)
|> result.map(fn(value) { #(key, value) })
})
}
/// Convert window_in_days to Honeycomb time_period (in days).
/// Range (1-90) is guaranteed by the standard library type constraint.
@internal
pub fn window_to_time_period(days: Int) -> Int {
days
}