diff --git a/composer.go b/composer.go new file mode 100644 index 0000000..60a4f6c --- /dev/null +++ b/composer.go @@ -0,0 +1,140 @@ +package metrics + +import ( + "fmt" + "reflect" + "strings" +) + +// LabelComposer lets you compose valid labels string +// Implement this interface if your want fast labels generation +// Otherwise it will fall back to a slow reflection-based implementation +type LabelComposer interface { + ToLabelsString() string +} + +// labelComposerMarker is a marker interface for enforcing type-safety of StructLabelComposer. +// Interface is private so it's only be used via StructLabelComposer implementation. +// This is made for safety reasons, so it's not allowed to pass a random struct to NameCompose() function. +type labelComposerMarker interface { + labelComposerMarker() +} + +// StructLabelComposer MUST be embedded in any struct that serves as a label composer. +// Embedding is required even if you provide custom implementation of LabelComposer (ToLabelsString() method) +type StructLabelComposer struct{} + +func (s StructLabelComposer) labelComposerMarker() { panic("should never happen") } + +// NameCompose returns a valid full metric name, composed of a metric name + stringified labels +// It will first try to use custom LabelComposer implementation (if any) +// then fallback to slow reflection-based implementation. +// +// The NameCompose can be called for further GetOrCreateCounter/etc func: +// +// // `my_counter{status="active",flag="false"}` +// GetOrCreateCounter(NameCompose("my_counter", MyLabels{ +// Status: "active", +// Flag: false, +// })).Inc() +func NameCompose(name string, lc labelComposerMarker) string { + if lc == nil { + return name + } + + // Implementing public LabelComposer means we must implement + // a custom ToLabelsString() that supposed to be fast. + if v, ok := lc.(LabelComposer); ok { + return name + v.ToLabelsString() + } + + // falling back to reflect-based implementation + // This is considered to be slow. Implement your own LabelComposer if it's an issue for you. + return name + reflectLabelCompose(lc) +} + +// reflectLabelCompose composes labels string {field="value",...} from a struct +// It will use only exported scalar fields, and will skip fields with the `-` tag. +// By default, the snake_cased field name is used as the label name. +// Label's name can be overridden by using the `labels` tag +func reflectLabelCompose(lc labelComposerMarker) string { + labelsStr := "{" + + val := reflect.Indirect(reflect.ValueOf(lc)) + typ := val.Type() + + var n int + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + if field.Anonymous || !field.IsExported() { + continue + } + ft := field.Type + if field.Type.Kind() == reflect.Pointer { + ft = field.Type.Elem() + } + fk := ft.Kind() + + // We only support basic scalar types: Strings, Numbers, Bool + if fk != reflect.String && fk != reflect.Bool && (fk < reflect.Int || fk > reflect.Uint64) { + continue + } + + var labelName string + if ourTag := field.Tag.Get(labelsTag); ourTag != "" { + if ourTag == "-" { // tag="-" means "skip this field" + continue + } + labelName = ourTag + } else { + labelName = toSnakeCase(field.Name) + } + + if n > 0 { + labelsStr += "," + } + labelsStr += labelName + `="` + stringifyLabelValue(val.Field(i)) + `"` + n++ + } + + return labelsStr + "}" +} + +// labelsTag is the tag name used for labels inside structs. +// The tag is optional, as if not present, field is used with snake_cased FieldName. +// It's useful to use a tag when you want to override the default naming or exclude a field from the metric. +var labelsTag = "labels" + +// SetLabelsStructTag sets the tag name used for labels inside structs. +func SetLabelsStructTag(tag string) { + labelsTag = tag +} + +// stringifyLabelValue makes up a valid string value from a given field's value +// It's used ONLY in fallback reflect mode +// Field value might be a pointer, that's why we do reflect.Indirect() +// Note: in future we can handle default values here as well +func stringifyLabelValue(v reflect.Value) string { + k := v.Kind() + if k == reflect.Ptr { + if v.IsNil() { + return "nil" + } + v = v.Elem() + } + + return fmt.Sprintf("%v", v.Interface()) +} + +// Convert struct field names to snake_case for Prometheus label compliance. +func toSnakeCase(s string) string { + s = strings.TrimSpace(s) + var result []rune + for i, r := range s { + if i > 0 && r >= 'A' && r <= 'Z' { + result = append(result, '_') + } + result = append(result, r) + } + return strings.ToLower(string(result)) +} diff --git a/composer_test.go b/composer_test.go new file mode 100644 index 0000000..70c7bcb --- /dev/null +++ b/composer_test.go @@ -0,0 +1,56 @@ +package metrics + +import ( + "fmt" + "testing" +) + +// MyLabelsSlow will be converted into {hello="world",enabled="true"} +// via reflect implementation. +// It's slow but completely automatic. You don't need to write any code +type MyLabelsSlow struct { + StructLabelComposer + + Status string + Flag bool +} + +func TestLabelComposeWithReflect(t *testing.T) { + want := `my_counter{status="active",flag="true"}` + + got := NameCompose("my_counter", MyLabelsSlow{ + Status: "active", + Flag: true, + }) + + if got != want { + t.Fatalf("unexpected full name; got %q; want %q", got, want) + } +} + +// MyLabelsFast will be converted into string +// via custom implementation (Using ToLabelsString() method of LabelComposer interface) +// It's fast but requires manual implementation. +type MyLabelsFast struct { + StructLabelComposer + Status string + Flag bool +} + +func (m *MyLabelsFast) ToLabelsString() string { + return "{" + + `status="` + m.Status + `",` + + `flag="` + fmt.Sprintf("%v", m.Flag) + `"` + + "}" +} + +func TestLabelComposeWithoutReflect(t *testing.T) { + want := `my_counter{status="active",flag="true"}` + got := NameCompose("my_counter", &MyLabelsFast{ + Status: "active", Flag: true, + }) + + if want != got { + t.Fatalf("unexpected full name; got %q; want %q", got, want) + } +}