diff --git a/buildscripts/check_go_output.sh b/buildscripts/check_go_output.sh index c1e0685..0e2fc6f 100755 --- a/buildscripts/check_go_output.sh +++ b/buildscripts/check_go_output.sh @@ -40,6 +40,16 @@ for test_dir in "${PACKAGE_DIR}"/tests/*/; do mkdir -p "${package_dir}" cp -R "${expected}/." "${package_dir}/" + # Hand-written consumer tests exercise the generated API the way a user of + # it would. They mirror expected's package layout, e.g. + # consumer/myappattr/attributes_test.go sits beside expected/myappattr, so + # they land in the same package directory and are gofmt-checked and + # type-checked alongside the generated code. + consumer="${test_dir}consumer" + if [[ -d "${consumer}" ]]; then + cp -R "${consumer}/." "${package_dir}/" + fi + # A test that maps an attribute namespace to a dependency registry, via # `imported_import_paths`, needs that dependency actually generated into the # module too, so the typed references it emits resolve to a real package. @@ -74,9 +84,65 @@ for test_dir in "${PACKAGE_DIR}"/tests/*/; do cd "${module_dir}" go mod init "${module_path}" >/dev/null 2>&1 go get "go.opentelemetry.io/otel@${OTEL_GO_VERSION}" "go.opentelemetry.io/otel/metric@${OTEL_GO_VERSION}" "go.opentelemetry.io/otel/trace@${OTEL_GO_VERSION}" "go.opentelemetry.io/otel/log@${OTEL_GO_LOG_VERSION}" "go.opentelemetry.io/otel/sdk@${OTEL_GO_VERSION}" 2>&1 | grep -v "^go: " || true + if [[ -d "${consumer}" ]]; then + go get "go.opentelemetry.io/otel/sdk/metric@${OTEL_GO_VERSION}" "go.opentelemetry.io/otel/sdk/log@${OTEL_GO_LOG_VERSION}" 2>&1 | grep -v "^go: " || true + fi go mod tidy go build ./... go vet ./... ) || { echo " FAIL: ${test_name} does not build."; exit 1; } echo " PASS: ${test_name} builds and vets." + + if [[ -d "${consumer}" ]]; then + ( + cd "${module_dir}" + go test ./... + ) || { echo " FAIL: ${test_name} consumer tests failed."; exit 1; } + echo " PASS: ${test_name} consumer tests pass." + fi + + compile_fail="${test_dir}compile-fail" + if [[ -d "${compile_fail}" ]]; then + for invalid_file in "${compile_fail}"/*.go; do + [[ -e "${invalid_file}" ]] || continue + case_name="$(basename "${invalid_file}" .go)" + expected_error="${compile_fail}/${case_name}.expected-error.txt" + if [[ ! -s "${expected_error}" ]]; then + echo " FAIL: ${test_name}/compile-fail/${case_name}.go has no ${case_name}.expected-error.txt, or it is empty." + exit 1 + fi + + cf_module_dir="${WORK_DIR}/${test_name}-compile-fail-${case_name}" + cf_package_dir="${cf_module_dir}${import_path#"${module_path}"}" + mkdir -p "${cf_package_dir}" + cp -R "${expected}/." "${cf_package_dir}/" + if [[ -f "${dependency_params}" ]]; then + cf_dependency_dir="${cf_module_dir}${dependency_import_path#"${module_path}"}" + mkdir -p "${cf_dependency_dir}" + cp -R "${dependency_observed}/." "${cf_dependency_dir}/" + fi + invalid_dir="${cf_module_dir}/invalidconsumer" + mkdir -p "${invalid_dir}" + cp "${invalid_file}" "${invalid_dir}/$(basename "${invalid_file}")" + + diagnostics="${WORK_DIR}/${test_name}-${case_name}.diagnostics.txt" + ( + cd "${cf_module_dir}" + go mod init "${module_path}" >/dev/null 2>&1 + go get "go.opentelemetry.io/otel@${OTEL_GO_VERSION}" "go.opentelemetry.io/otel/metric@${OTEL_GO_VERSION}" "go.opentelemetry.io/otel/trace@${OTEL_GO_VERSION}" "go.opentelemetry.io/otel/log@${OTEL_GO_LOG_VERSION}" "go.opentelemetry.io/otel/sdk@${OTEL_GO_VERSION}" 2>&1 | grep -v "^go: " || true + go mod tidy >/dev/null 2>&1 || true + ) || { echo " FAIL: ${test_name}/compile-fail/${case_name}.go: could not set up its module."; exit 1; } + + if ( cd "${cf_module_dir}" && go build ./... >"${diagnostics}" 2>&1 ); then + echo " FAIL: ${test_name}/compile-fail/${case_name}.go built, but it must not." + exit 1 + fi + if ! grep -Fqf "${expected_error}" "${diagnostics}"; then + echo " FAIL: ${test_name}/compile-fail/${case_name}.go failed, but not with the expected error:" + cat "${diagnostics}" + exit 1 + fi + echo " PASS: ${test_name}/compile-fail/${case_name}.go fails with the expected error." + done + fi done diff --git a/templates/code/go/README.md b/templates/code/go/README.md index a439f08..97014b8 100644 --- a/templates/code/go/README.md +++ b/templates/code/go/README.md @@ -73,6 +73,15 @@ enums of those. An enum takes the Go type of its members: `string`, `int64`, `float64` when any member is fractional, or `bool`. Template and `any` types are not rendered. +An enum's struct carries a hidden field marking it as one of the package's +members, so the zero value of an enum type, such as `myappattr.TaskState{}`, is +never a valid member even when an enum's value type makes the zero value look +otherwise valid, as a `boolean` enum with a `false` member does. `KeyValue`, and +every metric, span, event, and entity that records the attribute, detects the +zero value and drops the attribute instead of emitting one with an empty value. +The OpenTelemetry Go SDK drops invalid attributes the same way, and +`otel.Handle` is reserved for irremediable errors, so nothing is reported. + ### Root package The root package is named after the last element of `import_path`, as Go diff --git a/templates/code/go/attributes.go.j2 b/templates/code/go/attributes.go.j2 index 80ca17a..5b15b14 100644 --- a/templates/code/go/attributes.go.j2 +++ b/templates/code/go/attributes.go.j2 @@ -17,7 +17,14 @@ import "go.opentelemetry.io/otel/attribute" const {{ key }} = attribute.Key("{{ attr.key }}") {{ [name ~ " is a value of `" ~ attr.key ~ "`.", attr.brief, attr.note, deprecated] | select | join("\n\n") | comment }} +{%- if is_enum %} +type {{ name }} struct { + value {{ attr.value_type | map_text("attribute_field_type") }} + ok bool +} +{%- else %} type {{ name }} struct{ value {{ attr.value_type | map_text("attribute_field_type") }} } +{%- endif %} {% if not is_enum %} {{ ["New" ~ name ~ " returns a value of `" ~ attr.key ~ "`.", deprecated] | select | join("\n\n") | comment }} func New{{ name }}(value {{ attr.value_type | map_text("attribute_type_value") }}) {{ name }} { @@ -28,11 +35,20 @@ func New{{ name }}(value {{ attr.value_type | map_text("attribute_type_value") } func ({{ name }}) Key() attribute.Key { return {{ key }} } - +{% if is_enum %} +{{ ["KeyValue is the `" ~ attr.key ~ "` attribute set to this value.", "The zero " ~ name ~ " is not a valid value. KeyValue returns an invalid attribute.KeyValue for it, which the generated instruments, spans, events, and entities drop."] | select | join("\n\n") | comment }} +func (v {{ name }}) KeyValue() attribute.KeyValue { + if !v.ok { + return attribute.KeyValue{} + } + return {{ key }}.{{ attr.value_type | map_text("attribute_type_method") }}(v.value) +} +{%- else %} {{ ["KeyValue is the `" ~ attr.key ~ "` attribute set to this value."] | join | comment }} func (v {{ name }}) KeyValue() attribute.KeyValue { return {{ key }}.{{ attr.value_type | map_text("attribute_type_method") }}(v.value) } +{%- endif %} {% if is_enum %} var ( {% for member in attr.type.members %} @@ -40,7 +56,7 @@ var ( {% endif -%} {{ [base ~ n.exported(member.id) ~ " is the " ~ (member.value | print_member_value) ~ " member of `" ~ attr.key ~ "`.", member.brief, member.note, d.notice(member) or deprecated] | select | join("\n\n") | comment(format="go_indented") }} - {{ base }}{{ n.exported(member.id) }} = {{ name }}{value: {{ member.value | print_member_value }}} + {{ base }}{{ n.exported(member.id) }} = {{ name }}{value: {{ member.value | print_member_value }}, ok: true} {%- endfor %} ) {% endif -%} diff --git a/templates/code/go/entities.go.j2 b/templates/code/go/entities.go.j2 index deab650..7c0337e 100644 --- a/templates/code/go/entities.go.j2 +++ b/templates/code/go/entities.go.j2 @@ -64,11 +64,19 @@ func New{{ entity }}({% for req in def.required %}{% if not loop.first %}, {% en {%- if def.required or def.optional %} attrs := make([]attribute.KeyValue, 0, {{ def.required | length }}{% if def.optional %}+len(opts){% endif %}) {%- for req in def.required %} +{%- if req.imported %} attrs = append(attrs, {{ attr_kv(req, n.unexported(req.attr_param)) }}) +{%- else %} + if kv := {{ n.unexported(req.attr_param) }}.KeyValue(); kv.Valid() { + attrs = append(attrs, kv) + } +{%- endif %} {%- endfor %} {%- if def.optional %} for _, opt := range opts { - attrs = append(attrs, opt.{{ private }}EntityAttr()) + if kv := opt.{{ private }}EntityAttr(); kv.Valid() { + attrs = append(attrs, kv) + } } {%- endif %} return {{ entity }}{attrs: attrs} diff --git a/templates/code/go/events.go.j2 b/templates/code/go/events.go.j2 index e0553d0..5078f67 100644 --- a/templates/code/go/events.go.j2 +++ b/templates/code/go/events.go.j2 @@ -70,7 +70,13 @@ func With{{ name }}Timestamp(timestamp time.Time) {{ option }} { type {{ private }}Event{{ optname }} struct{ v {{ attr_type(opt) }} } func (o {{ private }}Event{{ optname }}) {{ private }}Option(record *log.Record) { +{%- if opt.imported %} record.AddAttributes({{ attr_kv(opt, "o.v") }}) +{%- else %} + if kv := o.v.KeyValue(); kv.Valid() { + record.AddAttributes(kv) + } +{%- endif %} } {{ ["With" ~ name ~ optname ~ " sets `" ~ opt.key ~ "` on the event.", opt.requirement_doc] | select | join("\n\n") | comment }} @@ -84,11 +90,17 @@ func {{ emit }}(ctx context.Context, logger {{ n.root() }}logger.Logger{% for re record.SetEventName({{ def.name | tojson }}) record.SetSeverity(log.SeverityInfo) {%- if def.required %} - record.AddAttributes( + kvs := make([]attribute.KeyValue, 0, {{ def.required | length }}) {%- for req in def.required %} - {{ attr_kv(req, n.unexported(req.attr_param)) }}, +{%- if req.imported %} + kvs = append(kvs, {{ attr_kv(req, n.unexported(req.attr_param)) }}) +{%- else %} + if kv := {{ n.unexported(req.attr_param) }}.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } +{%- endif %} {%- endfor %} - ) + record.AddAttributes(kvs...) {%- endif %} for _, opt := range opts { opt.{{ private }}Option(&record) diff --git a/templates/code/go/metrics.go.j2 b/templates/code/go/metrics.go.j2 index 9f53fa4..8b2d472 100644 --- a/templates/code/go/metrics.go.j2 +++ b/templates/code/go/metrics.go.j2 @@ -109,11 +109,19 @@ func (m {{ name }}) {{ method }}(ctx context.Context, {{ arg }} {{ gotype }}{% f {%- if def.required or def.optional %} kvs := make([]attribute.KeyValue, 0, {{ def.required | length }}{% if def.optional %}+len(opts){% endif %}) {%- for req in def.required %} +{%- if req.imported %} kvs = append(kvs, {{ attr_kv(req, n.unexported(req.attr_param)) }}) +{%- else %} + if kv := {{ n.unexported(req.attr_param) }}.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } +{%- endif %} {%- endfor %} {%- if def.optional %} for _, opt := range opts { - kvs = append(kvs, opt.{{ private }}Attr()) + if kv := opt.{{ private }}Attr(); kv.Valid() { + kvs = append(kvs, kv) + } } {%- endif %} m.inst.{{ method }}(ctx, {{ arg }}, metric.WithAttributes(kvs...)) @@ -139,11 +147,19 @@ func (o {{ observer }}) Observe(value {{ gotype }}{% for req in def.required %}, {%- if def.required or def.optional %} kvs := make([]attribute.KeyValue, 0, {{ def.required | length }}{% if def.optional %}+len(opts){% endif %}) {%- for req in def.required %} +{%- if req.imported %} kvs = append(kvs, {{ attr_kv(req, n.unexported(req.attr_param)) }}) +{%- else %} + if kv := {{ n.unexported(req.attr_param) }}.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } +{%- endif %} {%- endfor %} {%- if def.optional %} for _, opt := range opts { - kvs = append(kvs, opt.{{ private }}Attr()) + if kv := opt.{{ private }}Attr(); kv.Valid() { + kvs = append(kvs, kv) + } } {%- endif %} o.o.Observe(value, metric.WithAttributes(kvs...)) diff --git a/templates/code/go/spans.go.j2 b/templates/code/go/spans.go.j2 index b10fe35..c1636cc 100644 --- a/templates/code/go/spans.go.j2 +++ b/templates/code/go/spans.go.j2 @@ -102,7 +102,9 @@ func (s {{ span }}) End(opts ...trace.SpanEndOption) { func (s {{ span }}) SetAttributes(attrs ...{{ name }}Attr) { kvs := make([]attribute.KeyValue, 0, len(attrs)) for _, attr := range attrs { - kvs = append(kvs, attr.{{ private }}StartAttr()) + if kv := attr.{{ private }}StartAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } s.Span().SetAttributes(kvs...) } @@ -128,11 +130,19 @@ func {{ start }}(ctx context.Context, tracer {{ n.root() }}tracer.Tracer{% if no {%- if def.required or def.optional %} kvs := make([]attribute.KeyValue, 0, {{ def.required | length }}{% if def.optional %}+len(opts){% endif %}) {%- for req in def.required %} +{%- if req.imported %} kvs = append(kvs, {{ attr_kv(req, n.unexported(req.attr_param)) }}) +{%- else %} + if kv := {{ n.unexported(req.attr_param) }}.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } +{%- endif %} {%- endfor %} {%- if def.optional %} for _, opt := range opts { - kvs = append(kvs, opt.{{ private }}StartAttr()) + if kv := opt.{{ private }}StartAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } {%- endif %} {%- endif %} diff --git a/templates/code/go/tests/attributes/compile-fail/enum_from_outside_package.expected-error.txt b/templates/code/go/tests/attributes/compile-fail/enum_from_outside_package.expected-error.txt new file mode 100644 index 0000000..66a3597 --- /dev/null +++ b/templates/code/go/tests/attributes/compile-fail/enum_from_outside_package.expected-error.txt @@ -0,0 +1 @@ +cannot refer to unexported field value in struct literal of type myappattr.TaskLane diff --git a/templates/code/go/tests/attributes/compile-fail/enum_from_outside_package.go b/templates/code/go/tests/attributes/compile-fail/enum_from_outside_package.go new file mode 100644 index 0000000..fc8418f --- /dev/null +++ b/templates/code/go/tests/attributes/compile-fail/enum_from_outside_package.go @@ -0,0 +1,5 @@ +package invalidconsumer + +import "example.com/semconv/myappattr" + +var invalid = myappattr.TaskLane{value: "fast"} diff --git a/templates/code/go/tests/attributes/consumer/myappattr/attributes_test.go b/templates/code/go/tests/attributes/consumer/myappattr/attributes_test.go new file mode 100644 index 0000000..91b2423 --- /dev/null +++ b/templates/code/go/tests/attributes/consumer/myappattr/attributes_test.go @@ -0,0 +1,59 @@ +package myappattr_test + +import ( + "testing" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + + "example.com/semconv/myappattr" +) + +func TestKeyValue(t *testing.T) { + kv := myappattr.NewTaskID("t-1").KeyValue() + want := attribute.String("myapp.task.id", "t-1") + if kv != want { + t.Fatalf("KeyValue() = %v, want %v", kv, want) + } +} + +func TestEnumMemberKeyValue(t *testing.T) { + kv := myappattr.TaskStateRunning.KeyValue() + want := attribute.String("myapp.task.state", "running") + if kv != want { + t.Fatalf("KeyValue() = %v, want %v", kv, want) + } +} + +func TestZeroValueEnumDropsWithoutReporting(t *testing.T) { + var reported error + otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) { reported = err })) + t.Cleanup(func() { otel.SetErrorHandler(otel.ErrorHandlerFunc(func(error) {})) }) + + kv := myappattr.TaskState{}.KeyValue() + + if kv.Valid() { + t.Fatalf("KeyValue() = %v, want an invalid attribute.KeyValue", kv) + } + if reported != nil { + t.Fatalf("the zero TaskState reported %v, want nothing reported", reported) + } +} + +func TestZeroValueBooleanEnumIsNotItsFalseMember(t *testing.T) { + var reported error + otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) { reported = err })) + t.Cleanup(func() { otel.SetErrorHandler(otel.ErrorHandlerFunc(func(error) {})) }) + + if kv := myappattr.TaskPreemptibleForbidden.KeyValue(); !kv.Valid() || kv.Value.AsBool() { + t.Fatalf("TaskPreemptibleForbidden.KeyValue() = %v, want a valid false attribute", kv) + } + + zero := myappattr.TaskPreemptible{}.KeyValue() + if zero.Valid() { + t.Fatalf("the zero TaskPreemptible.KeyValue() = %v, want an invalid attribute", zero) + } + if reported != nil { + t.Fatalf("the zero TaskPreemptible reported %v, want nothing reported", reported) + } +} diff --git a/templates/code/go/tests/attributes/expected/authattr/attributes.go b/templates/code/go/tests/attributes/expected/authattr/attributes.go index 9a9866b..3b2e9bb 100644 --- a/templates/code/go/tests/attributes/expected/authattr/attributes.go +++ b/templates/code/go/tests/attributes/expected/authattr/attributes.go @@ -10,7 +10,10 @@ const levelKey = attribute.Key("auth.level") // Level is a value of `auth.level`. // // Assurance level of the authentication. -type Level struct{ value int } +type Level struct { + value int + ok bool +} // Key is the attribute key, for filters and views that need it. func (Level) Key() attribute.Key { @@ -18,7 +21,14 @@ func (Level) Key() attribute.Key { } // KeyValue is the `auth.level` attribute set to this value. +// +// The zero Level is not a valid value. KeyValue returns an invalid +// attribute.KeyValue for it, which the generated instruments, spans, events, +// and entities drop. func (v Level) KeyValue() attribute.KeyValue { + if !v.ok { + return attribute.KeyValue{} + } return levelKey.Int(v.value) } @@ -26,12 +36,12 @@ var ( // LevelLow is the 1 member of `auth.level`. // // Single factor. - LevelLow = Level{value: 1} + LevelLow = Level{value: 1, ok: true} // LevelHigh is the 2 member of `auth.level`. // // Multiple factors. - LevelHigh = Level{value: 2} + LevelHigh = Level{value: 2, ok: true} ) // methodKey is the attribute key of `auth.method`. @@ -40,7 +50,10 @@ const methodKey = attribute.Key("auth.method") // Method is a value of `auth.method`. // // How the user was authenticated. -type Method struct{ value string } +type Method struct { + value string + ok bool +} // Key is the attribute key, for filters and views that need it. func (Method) Key() attribute.Key { @@ -48,7 +61,14 @@ func (Method) Key() attribute.Key { } // KeyValue is the `auth.method` attribute set to this value. +// +// The zero Method is not a valid value. KeyValue returns an invalid +// attribute.KeyValue for it, which the generated instruments, spans, events, +// and entities drop. func (v Method) KeyValue() attribute.KeyValue { + if !v.ok { + return attribute.KeyValue{} + } return methodKey.String(v.value) } @@ -56,12 +76,12 @@ var ( // MethodPassword is the "password" member of `auth.method`. // // A password was checked. - MethodPassword = Method{value: "password"} + MethodPassword = Method{value: "password", ok: true} // MethodToken is the "token" member of `auth.method`. // // A bearer token was checked. - MethodToken = Method{value: "token"} + MethodToken = Method{value: "token", ok: true} ) // userIDKey is the attribute key of `auth.user.id`. diff --git a/templates/code/go/tests/attributes/expected/myappattr/attributes.go b/templates/code/go/tests/attributes/expected/myappattr/attributes.go index 37043d0..f8dc4b3 100644 --- a/templates/code/go/tests/attributes/expected/myappattr/attributes.go +++ b/templates/code/go/tests/attributes/expected/myappattr/attributes.go @@ -112,7 +112,10 @@ const taskLaneKey = attribute.Key("myapp.task.lane") // Lane the task was scheduled in. // // Deprecated: Lanes were replaced by priorities. -type TaskLane struct{ value string } +type TaskLane struct { + value string + ok bool +} // Key is the attribute key, for filters and views that need it. func (TaskLane) Key() attribute.Key { @@ -120,7 +123,14 @@ func (TaskLane) Key() attribute.Key { } // KeyValue is the `myapp.task.lane` attribute set to this value. +// +// The zero TaskLane is not a valid value. KeyValue returns an invalid +// attribute.KeyValue for it, which the generated instruments, spans, events, +// and entities drop. func (v TaskLane) KeyValue() attribute.KeyValue { + if !v.ok { + return attribute.KeyValue{} + } return taskLaneKey.String(v.value) } @@ -130,14 +140,14 @@ var ( // The fast lane. // // Deprecated: Lanes were replaced by priorities. - TaskLaneFast = TaskLane{value: "fast"} + TaskLaneFast = TaskLane{value: "fast", ok: true} // TaskLaneSlow is the "slow" member of `myapp.task.lane`. // // The slow lane. // // Deprecated: Every task runs in the fast lane. - TaskLaneSlow = TaskLane{value: "slow"} + TaskLaneSlow = TaskLane{value: "slow", ok: true} ) // taskOwnerKey is the attribute key of `myapp.task.owner`. @@ -175,7 +185,10 @@ const taskPreemptibleKey = attribute.Key("myapp.task.preemptible") // TaskPreemptible is a value of `myapp.task.preemptible`. // // Whether the scheduler may preempt the task. -type TaskPreemptible struct{ value bool } +type TaskPreemptible struct { + value bool + ok bool +} // Key is the attribute key, for filters and views that need it. func (TaskPreemptible) Key() attribute.Key { @@ -183,7 +196,14 @@ func (TaskPreemptible) Key() attribute.Key { } // KeyValue is the `myapp.task.preemptible` attribute set to this value. +// +// The zero TaskPreemptible is not a valid value. KeyValue returns an invalid +// attribute.KeyValue for it, which the generated instruments, spans, events, +// and entities drop. func (v TaskPreemptible) KeyValue() attribute.KeyValue { + if !v.ok { + return attribute.KeyValue{} + } return taskPreemptibleKey.Bool(v.value) } @@ -191,13 +211,13 @@ var ( // TaskPreemptibleAllowed is the true member of `myapp.task.preemptible`. // // The scheduler may preempt the task. - TaskPreemptibleAllowed = TaskPreemptible{value: true} + TaskPreemptibleAllowed = TaskPreemptible{value: true, ok: true} // TaskPreemptibleForbidden is the false member of // `myapp.task.preemptible`. // // The task runs to completion. - TaskPreemptibleForbidden = TaskPreemptible{value: false} + TaskPreemptibleForbidden = TaskPreemptible{value: false, ok: true} ) // taskProgressKey is the attribute key of `myapp.task.progress`. @@ -252,7 +272,10 @@ const taskSampleRateKey = attribute.Key("myapp.task.sample_rate") // TaskSampleRate is a value of `myapp.task.sample_rate`. // // Fraction of tasks whose telemetry is sampled. -type TaskSampleRate struct{ value float64 } +type TaskSampleRate struct { + value float64 + ok bool +} // Key is the attribute key, for filters and views that need it. func (TaskSampleRate) Key() attribute.Key { @@ -260,7 +283,14 @@ func (TaskSampleRate) Key() attribute.Key { } // KeyValue is the `myapp.task.sample_rate` attribute set to this value. +// +// The zero TaskSampleRate is not a valid value. KeyValue returns an invalid +// attribute.KeyValue for it, which the generated instruments, spans, events, +// and entities drop. func (v TaskSampleRate) KeyValue() attribute.KeyValue { + if !v.ok { + return attribute.KeyValue{} + } return taskSampleRateKey.Float64(v.value) } @@ -268,12 +298,12 @@ var ( // TaskSampleRateTenth is the 0.1 member of `myapp.task.sample_rate`. // // One task in ten is sampled. - TaskSampleRateTenth = TaskSampleRate{value: 0.1} + TaskSampleRateTenth = TaskSampleRate{value: 0.1, ok: true} // TaskSampleRateAll is the 1 member of `myapp.task.sample_rate`. // // Every task is sampled. - TaskSampleRateAll = TaskSampleRate{value: 1} + TaskSampleRateAll = TaskSampleRate{value: 1, ok: true} ) // taskShardIdsKey is the attribute key of `myapp.task.shard_ids`. @@ -305,7 +335,10 @@ const taskStateKey = attribute.Key("myapp.task.state") // TaskState is a value of `myapp.task.state`. // // Current state of the task. -type TaskState struct{ value string } +type TaskState struct { + value string + ok bool +} // Key is the attribute key, for filters and views that need it. func (TaskState) Key() attribute.Key { @@ -313,7 +346,14 @@ func (TaskState) Key() attribute.Key { } // KeyValue is the `myapp.task.state` attribute set to this value. +// +// The zero TaskState is not a valid value. KeyValue returns an invalid +// attribute.KeyValue for it, which the generated instruments, spans, events, +// and entities drop. func (v TaskState) KeyValue() attribute.KeyValue { + if !v.ok { + return attribute.KeyValue{} + } return taskStateKey.String(v.value) } @@ -321,21 +361,21 @@ var ( // TaskStateQueued is the "queued" member of `myapp.task.state`. // // The task is waiting to run. - TaskStateQueued = TaskState{value: "queued"} + TaskStateQueued = TaskState{value: "queued", ok: true} // TaskStateRunning is the "running" member of `myapp.task.state`. // // The task is running. // // A task stays running until it finishes or is cancelled. - TaskStateRunning = TaskState{value: "running"} + TaskStateRunning = TaskState{value: "running", ok: true} // TaskStatePaused is the "paused" member of `myapp.task.state`. // // The task was paused. // // Deprecated: Paused tasks are reported as queued. - TaskStatePaused = TaskState{value: "paused"} + TaskStatePaused = TaskState{value: "paused", ok: true} ) // taskTagsKey is the attribute key of `myapp.task.tags`. diff --git a/templates/code/go/tests/entities/consumer/myappentity/entities_test.go b/templates/code/go/tests/entities/consumer/myappentity/entities_test.go new file mode 100644 index 0000000..60b563b --- /dev/null +++ b/templates/code/go/tests/entities/consumer/myappentity/entities_test.go @@ -0,0 +1,53 @@ +package myappentity_test + +import ( + "testing" + + "go.opentelemetry.io/otel/attribute" + + "example.com/semconv/myappattr" + "example.com/semconv/myappentity" +) + +func TestNewHostRecordsRequiredAndOptionalAttributes(t *testing.T) { + host := myappentity.NewHost( + myappattr.NewHostName("h-1"), + myappentity.WithHostHostType(myappattr.HostTypePhysical), + myappentity.WithHostHostCPUCount(myappattr.NewHostCPUCount(4)), + ) + + want := attribute.NewSet( + myappattr.NewHostName("h-1").KeyValue(), + myappattr.HostTypePhysical.KeyValue(), + myappattr.NewHostCPUCount(4).KeyValue(), + ) + got := attribute.NewSet(host.Attributes()...) + if !got.Equals(&want) { + t.Fatalf("Attributes() = %v, want %v", got, want) + } + + resourceAttrs := attribute.NewSet(host.Resource().Attributes()...) + if !resourceAttrs.Equals(&want) { + t.Fatalf("Resource().Attributes() = %v, want %v", resourceAttrs, want) + } +} + +func TestNewHostDropsZeroValueOptionalAttribute(t *testing.T) { + host := myappentity.NewHost( + myappattr.NewHostName("h-2"), + myappentity.WithHostHostType(myappattr.HostType{}), + ) + + want := attribute.NewSet(myappattr.NewHostName("h-2").KeyValue()) + got := attribute.NewSet(host.Attributes()...) + if !got.Equals(&want) { + t.Fatalf("Attributes() = %v, want %v, the zero HostType must not be recorded", got, want) + } +} + +func TestZeroHostHasNoAttributes(t *testing.T) { + var host myappentity.Host + if attrs := host.Attributes(); len(attrs) != 0 { + t.Fatalf("Attributes() = %v, want none", attrs) + } +} diff --git a/templates/code/go/tests/entities/expected/myappattr/attributes.go b/templates/code/go/tests/entities/expected/myappattr/attributes.go index e6de88c..f33ef4c 100644 --- a/templates/code/go/tests/entities/expected/myappattr/attributes.go +++ b/templates/code/go/tests/entities/expected/myappattr/attributes.go @@ -85,7 +85,10 @@ const hostTypeKey = attribute.Key("myapp.host.type") // HostType is a value of `myapp.host.type`. // // Type of the host. -type HostType struct{ value string } +type HostType struct { + value string + ok bool +} // Key is the attribute key, for filters and views that need it. func (HostType) Key() attribute.Key { @@ -93,7 +96,14 @@ func (HostType) Key() attribute.Key { } // KeyValue is the `myapp.host.type` attribute set to this value. +// +// The zero HostType is not a valid value. KeyValue returns an invalid +// attribute.KeyValue for it, which the generated instruments, spans, events, +// and entities drop. func (v HostType) KeyValue() attribute.KeyValue { + if !v.ok { + return attribute.KeyValue{} + } return hostTypeKey.String(v.value) } @@ -101,12 +111,12 @@ var ( // HostTypePhysical is the "physical" member of `myapp.host.type`. // // A physical host. - HostTypePhysical = HostType{value: "physical"} + HostTypePhysical = HostType{value: "physical", ok: true} // HostTypeVirtual is the "virtual" member of `myapp.host.type`. // // A virtual host. - HostTypeVirtual = HostType{value: "virtual"} + HostTypeVirtual = HostType{value: "virtual", ok: true} ) // queueNameKey is the attribute key of `myapp.queue.name`. diff --git a/templates/code/go/tests/entities/expected/myappentity/entities.go b/templates/code/go/tests/entities/expected/myappentity/entities.go index c82f47e..965dd00 100644 --- a/templates/code/go/tests/entities/expected/myappentity/entities.go +++ b/templates/code/go/tests/entities/expected/myappentity/entities.go @@ -55,9 +55,13 @@ type Host struct{ attrs []attribute.KeyValue } // NewHost builds a Host from its required attributes and any given options. func NewHost(myappHostName myappattr.HostName, opts ...HostAttr) Host { attrs := make([]attribute.KeyValue, 0, 1+len(opts)) - attrs = append(attrs, myappHostName.KeyValue()) + if kv := myappHostName.KeyValue(); kv.Valid() { + attrs = append(attrs, kv) + } for _, opt := range opts { - attrs = append(attrs, opt.hostEntityAttr()) + if kv := opt.hostEntityAttr(); kv.Valid() { + attrs = append(attrs, kv) + } } return Host{attrs: attrs} } @@ -123,10 +127,16 @@ type HostWorker struct{ attrs []attribute.KeyValue } // given options. func NewHostWorker(myappHostName myappattr.HostName, myappHostRole myappattr.HostRole, opts ...HostWorkerAttr) HostWorker { attrs := make([]attribute.KeyValue, 0, 2+len(opts)) - attrs = append(attrs, myappHostName.KeyValue()) - attrs = append(attrs, myappHostRole.KeyValue()) + if kv := myappHostName.KeyValue(); kv.Valid() { + attrs = append(attrs, kv) + } + if kv := myappHostRole.KeyValue(); kv.Valid() { + attrs = append(attrs, kv) + } for _, opt := range opts { - attrs = append(attrs, opt.hostWorkerEntityAttr()) + if kv := opt.hostWorkerEntityAttr(); kv.Valid() { + attrs = append(attrs, kv) + } } return HostWorker{attrs: attrs} } @@ -175,9 +185,13 @@ type Queue struct{ attrs []attribute.KeyValue } // options. func NewQueue(myappQueueName myappattr.QueueName, opts ...QueueAttr) Queue { attrs := make([]attribute.KeyValue, 0, 1+len(opts)) - attrs = append(attrs, myappQueueName.KeyValue()) + if kv := myappQueueName.KeyValue(); kv.Valid() { + attrs = append(attrs, kv) + } for _, opt := range opts { - attrs = append(attrs, opt.queueEntityAttr()) + if kv := opt.queueEntityAttr(); kv.Valid() { + attrs = append(attrs, kv) + } } return Queue{attrs: attrs} } diff --git a/templates/code/go/tests/events/consumer/myappevent/events_test.go b/templates/code/go/tests/events/consumer/myappevent/events_test.go new file mode 100644 index 0000000..cb26ff6 --- /dev/null +++ b/templates/code/go/tests/events/consumer/myappevent/events_test.go @@ -0,0 +1,127 @@ +package myappevent_test + +import ( + "context" + "sync" + "testing" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/log" + sdklog "go.opentelemetry.io/otel/sdk/log" + + "example.com/semconv/myappattr" + "example.com/semconv/myappevent" + "example.com/semconv/semconvlogger" + "example.com/semconv/workerattr" +) + +type recordingExporter struct { + mu sync.Mutex + records []sdklog.Record +} + +func (e *recordingExporter) Export(ctx context.Context, records []sdklog.Record) error { + e.mu.Lock() + defer e.mu.Unlock() + for _, r := range records { + e.records = append(e.records, r.Clone()) + } + return nil +} + +func (e *recordingExporter) Shutdown(context.Context) error { return nil } + +func (e *recordingExporter) ForceFlush(context.Context) error { return nil } + +func (e *recordingExporter) snapshot() []sdklog.Record { + e.mu.Lock() + defer e.mu.Unlock() + out := make([]sdklog.Record, len(e.records)) + copy(out, e.records) + return out +} + +func newLogger(t *testing.T) (semconvlogger.Logger, *recordingExporter) { + t.Helper() + exporter := &recordingExporter{} + lp := sdklog.NewLoggerProvider(sdklog.WithProcessor(sdklog.NewSimpleProcessor(exporter))) + t.Cleanup(func() { _ = lp.Shutdown(context.Background()) }) + return semconvlogger.New(lp, "test"), exporter +} + +func attributesOf(r sdklog.Record) []attribute.KeyValue { + var out []attribute.KeyValue + r.WalkAttributes(func(kv attribute.KeyValue) bool { + out = append(out, kv) + return true + }) + return out +} + +func TestEmitTaskFinishedRecordsNameSeverityAndAttributes(t *testing.T) { + logger, exporter := newLogger(t) + + myappevent.EmitTaskFinished( + context.Background(), + logger, + myappattr.TaskStateDone, + myappevent.WithTaskFinishedTaskAttempt(myappattr.NewTaskAttempt(2)), + myappevent.WithTaskFinishedHostName(workerattr.NewHostName("worker-1")), + ) + + records := exporter.snapshot() + if len(records) != 1 { + t.Fatalf("len(records) = %d, want 1", len(records)) + } + r := records[0] + if got, want := r.EventName(), "myapp.task.finished"; got != want { + t.Fatalf("EventName() = %q, want %q", got, want) + } + if got, want := r.Severity(), log.SeverityInfo; got != want { + t.Fatalf("Severity() = %v, want %v", got, want) + } + want := attribute.NewSet( + myappattr.TaskStateDone.KeyValue(), + myappattr.NewTaskAttempt(2).KeyValue(), + workerattr.NewHostName("worker-1").KeyValue(), + ) + got := attribute.NewSet(attributesOf(r)...) + if !got.Equals(&want) { + t.Fatalf("attributes = %v, want %v", got, want) + } +} + +func TestEmitTaskFinishedDropsZeroValueRequiredAttribute(t *testing.T) { + logger, exporter := newLogger(t) + + myappevent.EmitTaskFinished(context.Background(), logger, myappattr.TaskState{}) + + records := exporter.snapshot() + if len(records) != 1 { + t.Fatalf("len(records) = %d, want 1", len(records)) + } + if attrs := attributesOf(records[0]); len(attrs) != 0 { + t.Fatalf("attributes = %v, want none, the zero TaskState must not be recorded", attrs) + } +} + +func TestWithTaskFinishedSeverityOverridesDefault(t *testing.T) { + logger, exporter := newLogger(t) + + myappevent.EmitTaskFinished( + context.Background(), + logger, + myappattr.TaskStateDone, + myappevent.WithTaskFinishedSeverity(log.SeverityError), + ) + + records := exporter.snapshot() + if got, want := records[0].Severity(), log.SeverityError; got != want { + t.Fatalf("Severity() = %v, want %v", got, want) + } +} + +func TestZeroLoggerEmitsNothing(t *testing.T) { + var logger semconvlogger.Logger + myappevent.EmitTaskFinished(context.Background(), logger, myappattr.TaskStateDone) +} diff --git a/templates/code/go/tests/events/expected/myappattr/attributes.go b/templates/code/go/tests/events/expected/myappattr/attributes.go index 992b6c5..6fe4a42 100644 --- a/templates/code/go/tests/events/expected/myappattr/attributes.go +++ b/templates/code/go/tests/events/expected/myappattr/attributes.go @@ -33,7 +33,10 @@ const taskStateKey = attribute.Key("myapp.task.state") // TaskState is a value of `myapp.task.state`. // // Current state of the task. -type TaskState struct{ value string } +type TaskState struct { + value string + ok bool +} // Key is the attribute key, for filters and views that need it. func (TaskState) Key() attribute.Key { @@ -41,7 +44,14 @@ func (TaskState) Key() attribute.Key { } // KeyValue is the `myapp.task.state` attribute set to this value. +// +// The zero TaskState is not a valid value. KeyValue returns an invalid +// attribute.KeyValue for it, which the generated instruments, spans, events, +// and entities drop. func (v TaskState) KeyValue() attribute.KeyValue { + if !v.ok { + return attribute.KeyValue{} + } return taskStateKey.String(v.value) } @@ -49,10 +59,10 @@ var ( // TaskStateDone is the "done" member of `myapp.task.state`. // // The task finished. - TaskStateDone = TaskState{value: "done"} + TaskStateDone = TaskState{value: "done", ok: true} // TaskStateFailed is the "failed" member of `myapp.task.state`. // // The task gave up. - TaskStateFailed = TaskState{value: "failed"} + TaskStateFailed = TaskState{value: "failed", ok: true} ) diff --git a/templates/code/go/tests/events/expected/myappevent/events.go b/templates/code/go/tests/events/expected/myappevent/events.go index 00a98df..7d0d228 100644 --- a/templates/code/go/tests/events/expected/myappevent/events.go +++ b/templates/code/go/tests/events/expected/myappevent/events.go @@ -6,6 +6,7 @@ import ( "context" "time" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/log" "example.com/semconv/myappattr" @@ -46,7 +47,9 @@ func WithTaskFinishedTimestamp(timestamp time.Time) TaskFinishedOption { type taskFinishedEventTaskAttempt struct{ v myappattr.TaskAttempt } func (o taskFinishedEventTaskAttempt) taskFinishedOption(record *log.Record) { - record.AddAttributes(o.v.KeyValue()) + if kv := o.v.KeyValue(); kv.Valid() { + record.AddAttributes(kv) + } } // WithTaskFinishedTaskAttempt sets `myapp.task.attempt` on the event. @@ -59,7 +62,9 @@ func WithTaskFinishedTaskAttempt(v myappattr.TaskAttempt) TaskFinishedOption { type taskFinishedEventHostName struct{ v workerattr.HostName } func (o taskFinishedEventHostName) taskFinishedOption(record *log.Record) { - record.AddAttributes(o.v.KeyValue()) + if kv := o.v.KeyValue(); kv.Valid() { + record.AddAttributes(kv) + } } // WithTaskFinishedHostName sets `worker.host.name` on the event. @@ -80,9 +85,11 @@ func EmitTaskFinished(ctx context.Context, logger semconvlogger.Logger, myappTas var record log.Record record.SetEventName("myapp.task.finished") record.SetSeverity(log.SeverityInfo) - record.AddAttributes( - myappTaskState.KeyValue(), - ) + kvs := make([]attribute.KeyValue, 0, 1) + if kv := myappTaskState.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } + record.AddAttributes(kvs...) for _, opt := range opts { opt.taskFinishedOption(&record) } @@ -136,9 +143,11 @@ func EmitTaskRetried(ctx context.Context, logger semconvlogger.Logger, myappTask var record log.Record record.SetEventName("myapp.task.retried") record.SetSeverity(log.SeverityInfo) - record.AddAttributes( - myappTaskAttempt.KeyValue(), - ) + kvs := make([]attribute.KeyValue, 0, 1) + if kv := myappTaskAttempt.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } + record.AddAttributes(kvs...) for _, opt := range opts { opt.taskRetriedOption(&record) } diff --git a/templates/code/go/tests/events/expected/workerevent/events.go b/templates/code/go/tests/events/expected/workerevent/events.go index 1b89f9c..46f831b 100644 --- a/templates/code/go/tests/events/expected/workerevent/events.go +++ b/templates/code/go/tests/events/expected/workerevent/events.go @@ -6,6 +6,7 @@ import ( "context" "time" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/log" "example.com/semconv/myappattr" @@ -94,7 +95,9 @@ func WithTaskFinishedTimestamp(timestamp time.Time) TaskFinishedOption { type taskFinishedEventTaskAttempt struct{ v myappattr.TaskAttempt } func (o taskFinishedEventTaskAttempt) taskFinishedOption(record *log.Record) { - record.AddAttributes(o.v.KeyValue()) + if kv := o.v.KeyValue(); kv.Valid() { + record.AddAttributes(kv) + } } // WithTaskFinishedTaskAttempt sets `myapp.task.attempt` on the event. @@ -115,10 +118,14 @@ func EmitTaskFinished(ctx context.Context, logger semconvlogger.Logger, myappTas var record log.Record record.SetEventName("worker.task.finished") record.SetSeverity(log.SeverityInfo) - record.AddAttributes( - myappTaskState.KeyValue(), - workerHostName.KeyValue(), - ) + kvs := make([]attribute.KeyValue, 0, 2) + if kv := myappTaskState.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } + if kv := workerHostName.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } + record.AddAttributes(kvs...) for _, opt := range opts { opt.taskFinishedOption(&record) } diff --git a/templates/code/go/tests/histogram_boundaries_by_unit/expected/myappmetric/metrics.go b/templates/code/go/tests/histogram_boundaries_by_unit/expected/myappmetric/metrics.go index 65e79c3..9af72ff 100644 --- a/templates/code/go/tests/histogram_boundaries_by_unit/expected/myappmetric/metrics.go +++ b/templates/code/go/tests/histogram_boundaries_by_unit/expected/myappmetric/metrics.go @@ -61,7 +61,9 @@ func (m TaskDuration) Record(ctx context.Context, value float64, myappTaskID mya return } kvs := make([]attribute.KeyValue, 0, 1) - kvs = append(kvs, myappTaskID.KeyValue()) + if kv := myappTaskID.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } m.inst.Record(ctx, value, metric.WithAttributes(kvs...)) } @@ -113,7 +115,9 @@ func (m TaskLatency) Record(ctx context.Context, value float64, myappTaskID myap return } kvs := make([]attribute.KeyValue, 0, 1) - kvs = append(kvs, myappTaskID.KeyValue()) + if kv := myappTaskID.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } m.inst.Record(ctx, value, metric.WithAttributes(kvs...)) } @@ -167,7 +171,9 @@ func (m TaskRetryDuration) Record(ctx context.Context, value float64, myappTaskI return } kvs := make([]attribute.KeyValue, 0, 1) - kvs = append(kvs, myappTaskID.KeyValue()) + if kv := myappTaskID.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } m.inst.Record(ctx, value, metric.WithAttributes(kvs...)) } @@ -218,6 +224,8 @@ func (m TaskSize) Record(ctx context.Context, value float64, myappTaskID myappat return } kvs := make([]attribute.KeyValue, 0, 1) - kvs = append(kvs, myappTaskID.KeyValue()) + if kv := myappTaskID.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } m.inst.Record(ctx, value, metric.WithAttributes(kvs...)) } diff --git a/templates/code/go/tests/histogram_boundaries_by_unit_params/expected/myappmetric/metrics.go b/templates/code/go/tests/histogram_boundaries_by_unit_params/expected/myappmetric/metrics.go index d931174..a7452f7 100644 --- a/templates/code/go/tests/histogram_boundaries_by_unit_params/expected/myappmetric/metrics.go +++ b/templates/code/go/tests/histogram_boundaries_by_unit_params/expected/myappmetric/metrics.go @@ -60,7 +60,9 @@ func (m TaskDuration) Record(ctx context.Context, value float64, myappTaskID mya return } kvs := make([]attribute.KeyValue, 0, 1) - kvs = append(kvs, myappTaskID.KeyValue()) + if kv := myappTaskID.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } m.inst.Record(ctx, value, metric.WithAttributes(kvs...)) } @@ -113,6 +115,8 @@ func (m TaskQueueWait) Record(ctx context.Context, value float64, myappTaskID my return } kvs := make([]attribute.KeyValue, 0, 1) - kvs = append(kvs, myappTaskID.KeyValue()) + if kv := myappTaskID.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } m.inst.Record(ctx, value, metric.WithAttributes(kvs...)) } diff --git a/templates/code/go/tests/imported/consumer/myappmetric/metrics_test.go b/templates/code/go/tests/imported/consumer/myappmetric/metrics_test.go new file mode 100644 index 0000000..69bd841 --- /dev/null +++ b/templates/code/go/tests/imported/consumer/myappmetric/metrics_test.go @@ -0,0 +1,63 @@ +package myappmetric_test + +import ( + "context" + "testing" + + "go.opentelemetry.io/otel/attribute" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + + "example.com/semconv/myappattr" + "example.com/semconv/myappmetric" + "example.com/semconv/semconvmeter" +) + +func TestTaskFailedRecordsRawAndTypedAttributes(t *testing.T) { + reader := sdkmetric.NewManualReader() + mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + meter := semconvmeter.New(mp, "test") + + taskFailed, err := myappmetric.NewTaskFailed(meter) + if err != nil { + t.Fatalf("NewTaskFailed() error = %v", err) + } + + taskFailed.Add( + context.Background(), + 1, + "timeout", + myappattr.NewTaskID("t-1"), + myappmetric.WithTaskFailedHTTPRequestMethodList([]string{"GET", "POST"}), + ) + + var rm metricdata.ResourceMetrics + if err := reader.Collect(context.Background(), &rm); err != nil { + t.Fatalf("Collect() error = %v", err) + } + var m metricdata.Metrics + var found bool + for _, sm := range rm.ScopeMetrics { + for _, candidate := range sm.Metrics { + if candidate.Name == "myapp.task.failed" { + m, found = candidate, true + } + } + } + if !found { + t.Fatal("myapp.task.failed was not recorded") + } + sum := m.Data.(metricdata.Sum[int64]) + if len(sum.DataPoints) != 1 { + t.Fatalf("len(DataPoints) = %d, want 1", len(sum.DataPoints)) + } + want := attribute.NewSet( + attribute.String("error.type", "timeout"), + myappattr.NewTaskID("t-1").KeyValue(), + attribute.StringSlice("http.request.method_list", []string{"GET", "POST"}), + ) + got := sum.DataPoints[0].Attributes + if !got.Equals(&want) { + t.Fatalf("Attributes = %v, want %v", got, want) + } +} diff --git a/templates/code/go/tests/imported/expected/edgeevent/events.go b/templates/code/go/tests/imported/expected/edgeevent/events.go index 0090945..d7553af 100644 --- a/templates/code/go/tests/imported/expected/edgeevent/events.go +++ b/templates/code/go/tests/imported/expected/edgeevent/events.go @@ -62,9 +62,9 @@ func EmitUpstreamFailed(ctx context.Context, logger semconvlogger.Logger, errorT var record log.Record record.SetEventName("edge.upstream.failed") record.SetSeverity(log.SeverityInfo) - record.AddAttributes( - attribute.String("error.type", errorType), - ) + kvs := make([]attribute.KeyValue, 0, 1) + kvs = append(kvs, attribute.String("error.type", errorType)) + record.AddAttributes(kvs...) for _, opt := range opts { opt.upstreamFailedOption(&record) } diff --git a/templates/code/go/tests/imported/expected/edgemetric/metrics.go b/templates/code/go/tests/imported/expected/edgemetric/metrics.go index c4d5ff0..c6de4e9 100644 --- a/templates/code/go/tests/imported/expected/edgemetric/metrics.go +++ b/templates/code/go/tests/imported/expected/edgemetric/metrics.go @@ -80,7 +80,9 @@ func (m UpstreamDuration) Record(ctx context.Context, value float64, serverAddre kvs := make([]attribute.KeyValue, 0, 1+len(opts)) kvs = append(kvs, attribute.String("server.address", serverAddress)) for _, opt := range opts { - kvs = append(kvs, opt.upstreamDurationAttr()) + if kv := opt.upstreamDurationAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } m.inst.Record(ctx, value, metric.WithAttributes(kvs...)) } diff --git a/templates/code/go/tests/imported/expected/edgespan/spans.go b/templates/code/go/tests/imported/expected/edgespan/spans.go index 31e2325..b4e56b6 100644 --- a/templates/code/go/tests/imported/expected/edgespan/spans.go +++ b/templates/code/go/tests/imported/expected/edgespan/spans.go @@ -77,7 +77,9 @@ func (s UpstreamRequest) End(opts ...trace.SpanEndOption) { func (s UpstreamRequest) SetAttributes(attrs ...UpstreamRequestAttr) { kvs := make([]attribute.KeyValue, 0, len(attrs)) for _, attr := range attrs { - kvs = append(kvs, attr.upstreamRequestStartAttr()) + if kv := attr.upstreamRequestStartAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } s.Span().SetAttributes(kvs...) } @@ -104,7 +106,9 @@ func StartUpstreamRequest(ctx context.Context, tracer semconvtracer.Tracer, serv kvs = append(kvs, attribute.String("server.address", serverAddress)) kvs = append(kvs, attribute.Int("server.port", serverPort)) for _, opt := range opts { - kvs = append(kvs, opt.upstreamRequestStartAttr()) + if kv := opt.upstreamRequestStartAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } name := attribute.String("server.address", serverAddress).Value.Emit() + ":" + attribute.Int("server.port", serverPort).Value.Emit() ctx, span := tracer.TraceTracer().Start( diff --git a/templates/code/go/tests/imported/expected/myappmetric/metrics.go b/templates/code/go/tests/imported/expected/myappmetric/metrics.go index dd399db..5b56ae7 100644 --- a/templates/code/go/tests/imported/expected/myappmetric/metrics.go +++ b/templates/code/go/tests/imported/expected/myappmetric/metrics.go @@ -79,9 +79,13 @@ func (m TaskFailed) Add(ctx context.Context, incr int64, errorType string, myapp } kvs := make([]attribute.KeyValue, 0, 2+len(opts)) kvs = append(kvs, attribute.String("error.type", errorType)) - kvs = append(kvs, myappTaskID.KeyValue()) + if kv := myappTaskID.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } for _, opt := range opts { - kvs = append(kvs, opt.taskFailedAttr()) + if kv := opt.taskFailedAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } m.inst.Add(ctx, incr, metric.WithAttributes(kvs...)) } @@ -100,9 +104,13 @@ func (o TaskFailedObserver) Observe(value int64, errorType string, myappTaskID m } kvs := make([]attribute.KeyValue, 0, 2+len(opts)) kvs = append(kvs, attribute.String("error.type", errorType)) - kvs = append(kvs, myappTaskID.KeyValue()) + if kv := myappTaskID.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } for _, opt := range opts { - kvs = append(kvs, opt.taskFailedAttr()) + if kv := opt.taskFailedAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } o.o.Observe(value, metric.WithAttributes(kvs...)) } diff --git a/templates/code/go/tests/imported_typed/expected/myappevent/events.go b/templates/code/go/tests/imported_typed/expected/myappevent/events.go index 163e72c..ca087d3 100644 --- a/templates/code/go/tests/imported_typed/expected/myappevent/events.go +++ b/templates/code/go/tests/imported_typed/expected/myappevent/events.go @@ -63,9 +63,11 @@ func EmitTaskErrored(ctx context.Context, logger myapplogger.Logger, errorType e var record log.Record record.SetEventName("myapp.task.errored") record.SetSeverity(log.SeverityInfo) - record.AddAttributes( - errorType.KeyValue(), - ) + kvs := make([]attribute.KeyValue, 0, 1) + if kv := errorType.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } + record.AddAttributes(kvs...) for _, opt := range opts { opt.taskErroredOption(&record) } diff --git a/templates/code/go/tests/imported_typed/expected/myappmetric/metrics.go b/templates/code/go/tests/imported_typed/expected/myappmetric/metrics.go index d4b249f..ee441e3 100644 --- a/templates/code/go/tests/imported_typed/expected/myappmetric/metrics.go +++ b/templates/code/go/tests/imported_typed/expected/myappmetric/metrics.go @@ -78,10 +78,16 @@ func (m TaskFailed) Add(ctx context.Context, incr int64, errorType errorattr.Typ return } kvs := make([]attribute.KeyValue, 0, 2+len(opts)) - kvs = append(kvs, errorType.KeyValue()) - kvs = append(kvs, myappTaskID.KeyValue()) + if kv := errorType.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } + if kv := myappTaskID.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } for _, opt := range opts { - kvs = append(kvs, opt.taskFailedAttr()) + if kv := opt.taskFailedAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } m.inst.Add(ctx, incr, metric.WithAttributes(kvs...)) } @@ -99,10 +105,16 @@ func (o TaskFailedObserver) Observe(value int64, errorType errorattr.Type, myapp return } kvs := make([]attribute.KeyValue, 0, 2+len(opts)) - kvs = append(kvs, errorType.KeyValue()) - kvs = append(kvs, myappTaskID.KeyValue()) + if kv := errorType.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } + if kv := myappTaskID.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } for _, opt := range opts { - kvs = append(kvs, opt.taskFailedAttr()) + if kv := opt.taskFailedAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } o.o.Observe(value, metric.WithAttributes(kvs...)) } diff --git a/templates/code/go/tests/imported_typed/expected/myappspan/spans.go b/templates/code/go/tests/imported_typed/expected/myappspan/spans.go index cd29188..2b638ec 100644 --- a/templates/code/go/tests/imported_typed/expected/myappspan/spans.go +++ b/templates/code/go/tests/imported_typed/expected/myappspan/spans.go @@ -64,7 +64,9 @@ func (s TaskDispatch) End(opts ...trace.SpanEndOption) { func (s TaskDispatch) SetAttributes(attrs ...TaskDispatchAttr) { kvs := make([]attribute.KeyValue, 0, len(attrs)) for _, attr := range attrs { - kvs = append(kvs, attr.taskDispatchStartAttr()) + if kv := attr.taskDispatchStartAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } s.Span().SetAttributes(kvs...) } @@ -88,10 +90,16 @@ func (s TaskDispatch) SetStatus(code codes.Code, description string) { // each placeholder replaced by the value of the attribute it names. func StartTaskDispatch(ctx context.Context, tracer myapptracer.Tracer, errorType errorattr.Type, myappTaskID myappattr.TaskID, opts ...TaskDispatchStartAttr) (context.Context, TaskDispatch) { kvs := make([]attribute.KeyValue, 0, 2+len(opts)) - kvs = append(kvs, errorType.KeyValue()) - kvs = append(kvs, myappTaskID.KeyValue()) + if kv := errorType.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } + if kv := myappTaskID.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } for _, opt := range opts { - kvs = append(kvs, opt.taskDispatchStartAttr()) + if kv := opt.taskDispatchStartAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } name := "task " + myappTaskID.KeyValue().Value.Emit() + " failed with " + errorType.KeyValue().Value.Emit() ctx, span := tracer.TraceTracer().Start( diff --git a/templates/code/go/tests/metrics/compile-fail/wrong_option_type.expected-error.txt b/templates/code/go/tests/metrics/compile-fail/wrong_option_type.expected-error.txt new file mode 100644 index 0000000..5225f3e --- /dev/null +++ b/templates/code/go/tests/metrics/compile-fail/wrong_option_type.expected-error.txt @@ -0,0 +1 @@ +does not implement myappmetric.TaskDurationAttr (missing method taskDurationAttr) diff --git a/templates/code/go/tests/metrics/compile-fail/wrong_option_type.go b/templates/code/go/tests/metrics/compile-fail/wrong_option_type.go new file mode 100644 index 0000000..37f95c6 --- /dev/null +++ b/templates/code/go/tests/metrics/compile-fail/wrong_option_type.go @@ -0,0 +1,13 @@ +package invalidconsumer + +import ( + "context" + + "example.com/semconv/myappattr" + "example.com/semconv/myappmetric" +) + +func invalid() { + var m myappmetric.TaskDuration + m.Record(context.Background(), 0.1, myappattr.NewTaskID("t-1"), "oops") +} diff --git a/templates/code/go/tests/metrics/consumer/myappmetric/metrics_test.go b/templates/code/go/tests/metrics/consumer/myappmetric/metrics_test.go new file mode 100644 index 0000000..9dc2e17 --- /dev/null +++ b/templates/code/go/tests/metrics/consumer/myappmetric/metrics_test.go @@ -0,0 +1,177 @@ +package myappmetric_test + +import ( + "context" + "testing" + + "go.opentelemetry.io/otel/attribute" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + + "example.com/semconv/authattr" + "example.com/semconv/myappattr" + "example.com/semconv/myappmetric" + "example.com/semconv/semconvmeter" +) + +func collect(t *testing.T, reader *sdkmetric.ManualReader) metricdata.ResourceMetrics { + t.Helper() + var rm metricdata.ResourceMetrics + if err := reader.Collect(context.Background(), &rm); err != nil { + t.Fatalf("Collect() error = %v", err) + } + return rm +} + +func findMetric(rm metricdata.ResourceMetrics, name string) (metricdata.Metrics, bool) { + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + if m.Name == name { + return m, true + } + } + } + return metricdata.Metrics{}, false +} + +func TestTaskActiveRecordsRequiredAttribute(t *testing.T) { + reader := sdkmetric.NewManualReader() + mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + meter := semconvmeter.New(mp, "test") + + taskActive, err := myappmetric.NewTaskActive(meter) + if err != nil { + t.Fatalf("NewTaskActive() error = %v", err) + } + if got, want := taskActive.Name(), "myapp.task.active"; got != want { + t.Fatalf("Name() = %q, want %q", got, want) + } + if got, want := taskActive.Unit(), "{task}"; got != want { + t.Fatalf("Unit() = %q, want %q", got, want) + } + if got, want := taskActive.Description(), "Number of tasks currently running."; got != want { + t.Fatalf("Description() = %q, want %q", got, want) + } + if taskActive.Inst() == nil { + t.Fatal("Inst() = nil, want a non-nil instrument") + } + + taskActive.Add(context.Background(), 1, myappattr.TaskStateDone) + + m, ok := findMetric(collect(t, reader), "myapp.task.active") + if !ok { + t.Fatal("myapp.task.active was not recorded") + } + sum, ok := m.Data.(metricdata.Sum[int64]) + if !ok { + t.Fatalf("Data = %T, want metricdata.Sum[int64]", m.Data) + } + if len(sum.DataPoints) != 1 { + t.Fatalf("len(DataPoints) = %d, want 1", len(sum.DataPoints)) + } + dp := sum.DataPoints[0] + if dp.Value != 1 { + t.Fatalf("Value = %d, want 1", dp.Value) + } + want := attribute.NewSet(myappattr.TaskStateDone.KeyValue()) + if !dp.Attributes.Equals(&want) { + t.Fatalf("Attributes = %v, want %v", dp.Attributes, want) + } +} + +func TestZeroValueEnumAttributeIsDroppedNotPanicked(t *testing.T) { + reader := sdkmetric.NewManualReader() + mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + meter := semconvmeter.New(mp, "test") + + taskActive, err := myappmetric.NewTaskActive(meter) + if err != nil { + t.Fatalf("NewTaskActive() error = %v", err) + } + + taskActive.Add(context.Background(), 1, myappattr.TaskState{}) + + m, ok := findMetric(collect(t, reader), "myapp.task.active") + if !ok { + t.Fatal("myapp.task.active was not recorded") + } + sum := m.Data.(metricdata.Sum[int64]) + if len(sum.DataPoints) != 1 { + t.Fatalf("len(DataPoints) = %d, want 1", len(sum.DataPoints)) + } + if n := sum.DataPoints[0].Attributes.Len(); n != 0 { + t.Fatalf("Attributes.Len() = %d, want 0, the zero TaskState must not be recorded", n) + } +} + +func TestTaskDurationRecordsRequiredAndOptionalAttributes(t *testing.T) { + reader := sdkmetric.NewManualReader() + mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + meter := semconvmeter.New(mp, "test") + + taskDuration, err := myappmetric.NewTaskDuration(meter) + if err != nil { + t.Fatalf("NewTaskDuration() error = %v", err) + } + + taskDuration.Record( + context.Background(), + 0.25, + myappattr.NewTaskID("t-1"), + myappmetric.WithTaskDurationMethod(authattr.MethodToken), + ) + + m, ok := findMetric(collect(t, reader), "myapp.task.duration") + if !ok { + t.Fatal("myapp.task.duration was not recorded") + } + histogram, ok := m.Data.(metricdata.Histogram[float64]) + if !ok { + t.Fatalf("Data = %T, want metricdata.Histogram[float64]", m.Data) + } + if len(histogram.DataPoints) != 1 { + t.Fatalf("len(DataPoints) = %d, want 1", len(histogram.DataPoints)) + } + dp := histogram.DataPoints[0] + want := attribute.NewSet(myappattr.NewTaskID("t-1").KeyValue(), authattr.MethodToken.KeyValue()) + if !dp.Attributes.Equals(&want) { + t.Fatalf("Attributes = %v, want %v", dp.Attributes, want) + } +} + +func TestTaskDurationDropsZeroValueOptionalAttribute(t *testing.T) { + reader := sdkmetric.NewManualReader() + mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + meter := semconvmeter.New(mp, "test") + + taskDuration, err := myappmetric.NewTaskDuration(meter) + if err != nil { + t.Fatalf("NewTaskDuration() error = %v", err) + } + + taskDuration.Record( + context.Background(), + 0.25, + myappattr.NewTaskID("t-1"), + myappmetric.WithTaskDurationMethod(authattr.Method{}), + ) + + m, ok := findMetric(collect(t, reader), "myapp.task.duration") + if !ok { + t.Fatal("myapp.task.duration was not recorded") + } + histogram := m.Data.(metricdata.Histogram[float64]) + dp := histogram.DataPoints[0] + want := attribute.NewSet(myappattr.NewTaskID("t-1").KeyValue()) + if !dp.Attributes.Equals(&want) { + t.Fatalf("Attributes = %v, want %v, the zero Method must not be recorded", dp.Attributes, want) + } +} + +func TestZeroTaskActiveRecordsNothing(t *testing.T) { + var taskActive myappmetric.TaskActive + taskActive.Add(context.Background(), 1, myappattr.TaskStateDone) + if taskActive.Inst() != nil { + t.Fatal("Inst() of the zero TaskActive is not nil") + } +} diff --git a/templates/code/go/tests/metrics/expected/authattr/attributes.go b/templates/code/go/tests/metrics/expected/authattr/attributes.go index 75e9ae0..cc00e6e 100644 --- a/templates/code/go/tests/metrics/expected/authattr/attributes.go +++ b/templates/code/go/tests/metrics/expected/authattr/attributes.go @@ -10,7 +10,10 @@ const methodKey = attribute.Key("auth.method") // Method is a value of `auth.method`. // // How the user was authenticated. -type Method struct{ value string } +type Method struct { + value string + ok bool +} // Key is the attribute key, for filters and views that need it. func (Method) Key() attribute.Key { @@ -18,7 +21,14 @@ func (Method) Key() attribute.Key { } // KeyValue is the `auth.method` attribute set to this value. +// +// The zero Method is not a valid value. KeyValue returns an invalid +// attribute.KeyValue for it, which the generated instruments, spans, events, +// and entities drop. func (v Method) KeyValue() attribute.KeyValue { + if !v.ok { + return attribute.KeyValue{} + } return methodKey.String(v.value) } @@ -26,12 +36,12 @@ var ( // MethodPassword is the "password" member of `auth.method`. // // A password was checked. - MethodPassword = Method{value: "password"} + MethodPassword = Method{value: "password", ok: true} // MethodToken is the "token" member of `auth.method`. // // A bearer token was checked. - MethodToken = Method{value: "token"} + MethodToken = Method{value: "token", ok: true} ) // successKey is the attribute key of `auth.success`. diff --git a/templates/code/go/tests/metrics/expected/authmetric/metrics.go b/templates/code/go/tests/metrics/expected/authmetric/metrics.go index faec58d..555e84e 100644 --- a/templates/code/go/tests/metrics/expected/authmetric/metrics.go +++ b/templates/code/go/tests/metrics/expected/authmetric/metrics.go @@ -60,8 +60,12 @@ func (m Attempts) Add(ctx context.Context, incr int64, authMethod authattr.Metho return } kvs := make([]attribute.KeyValue, 0, 2) - kvs = append(kvs, authMethod.KeyValue()) - kvs = append(kvs, authSuccess.KeyValue()) + if kv := authMethod.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } + if kv := authSuccess.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } m.inst.Add(ctx, incr, metric.WithAttributes(kvs...)) } @@ -77,8 +81,12 @@ func (o AttemptsObserver) Observe(value int64, authMethod authattr.Method, authS return } kvs := make([]attribute.KeyValue, 0, 2) - kvs = append(kvs, authMethod.KeyValue()) - kvs = append(kvs, authSuccess.KeyValue()) + if kv := authMethod.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } + if kv := authSuccess.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } o.o.Observe(value, metric.WithAttributes(kvs...)) } diff --git a/templates/code/go/tests/metrics/expected/myappattr/attributes.go b/templates/code/go/tests/metrics/expected/myappattr/attributes.go index 85499e4..2842b68 100644 --- a/templates/code/go/tests/metrics/expected/myappattr/attributes.go +++ b/templates/code/go/tests/metrics/expected/myappattr/attributes.go @@ -33,7 +33,10 @@ const taskStateKey = attribute.Key("myapp.task.state") // TaskState is a value of `myapp.task.state`. // // Current state of the task. -type TaskState struct{ value string } +type TaskState struct { + value string + ok bool +} // Key is the attribute key, for filters and views that need it. func (TaskState) Key() attribute.Key { @@ -41,7 +44,14 @@ func (TaskState) Key() attribute.Key { } // KeyValue is the `myapp.task.state` attribute set to this value. +// +// The zero TaskState is not a valid value. KeyValue returns an invalid +// attribute.KeyValue for it, which the generated instruments, spans, events, +// and entities drop. func (v TaskState) KeyValue() attribute.KeyValue { + if !v.ok { + return attribute.KeyValue{} + } return taskStateKey.String(v.value) } @@ -49,10 +59,10 @@ var ( // TaskStateQueued is the "queued" member of `myapp.task.state`. // // The task is waiting to run. - TaskStateQueued = TaskState{value: "queued"} + TaskStateQueued = TaskState{value: "queued", ok: true} // TaskStateDone is the "done" member of `myapp.task.state`. // // The task finished. - TaskStateDone = TaskState{value: "done"} + TaskStateDone = TaskState{value: "done", ok: true} ) diff --git a/templates/code/go/tests/metrics/expected/myappmetric/metrics.go b/templates/code/go/tests/metrics/expected/myappmetric/metrics.go index 8bcc6a3..c5b1817 100644 --- a/templates/code/go/tests/metrics/expected/myappmetric/metrics.go +++ b/templates/code/go/tests/metrics/expected/myappmetric/metrics.go @@ -190,7 +190,9 @@ func (m TaskActive) Add(ctx context.Context, incr int64, myappTaskState myappatt return } kvs := make([]attribute.KeyValue, 0, 1) - kvs = append(kvs, myappTaskState.KeyValue()) + if kv := myappTaskState.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } m.inst.Add(ctx, incr, metric.WithAttributes(kvs...)) } @@ -207,7 +209,9 @@ func (o TaskActiveObserver) Observe(value int64, myappTaskState myappattr.TaskSt return } kvs := make([]attribute.KeyValue, 0, 1) - kvs = append(kvs, myappTaskState.KeyValue()) + if kv := myappTaskState.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } o.o.Observe(value, metric.WithAttributes(kvs...)) } @@ -348,9 +352,13 @@ func (m TaskDuration) Record(ctx context.Context, value float64, myappTaskID mya return } kvs := make([]attribute.KeyValue, 0, 1+len(opts)) - kvs = append(kvs, myappTaskID.KeyValue()) + if kv := myappTaskID.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } for _, opt := range opts { - kvs = append(kvs, opt.taskDurationAttr()) + if kv := opt.taskDurationAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } m.inst.Record(ctx, value, metric.WithAttributes(kvs...)) } @@ -426,10 +434,16 @@ func (m TaskRetryDuration) Record(ctx context.Context, value float64, myappTaskI return } kvs := make([]attribute.KeyValue, 0, 2+len(opts)) - kvs = append(kvs, myappTaskID.KeyValue()) - kvs = append(kvs, myappTaskState.KeyValue()) + if kv := myappTaskID.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } + if kv := myappTaskState.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } for _, opt := range opts { - kvs = append(kvs, opt.taskRetryDurationAttr()) + if kv := opt.taskRetryDurationAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } m.inst.Record(ctx, value, metric.WithAttributes(kvs...)) } @@ -507,7 +521,9 @@ func (m TaskStarted) Add(ctx context.Context, incr int64, opts ...TaskStartedAtt } kvs := make([]attribute.KeyValue, 0, 0+len(opts)) for _, opt := range opts { - kvs = append(kvs, opt.taskStartedAttr()) + if kv := opt.taskStartedAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } m.inst.Add(ctx, incr, metric.WithAttributes(kvs...)) } @@ -526,7 +542,9 @@ func (o TaskStartedObserver) Observe(value int64, opts ...TaskStartedAttr) { } kvs := make([]attribute.KeyValue, 0, 0+len(opts)) for _, opt := range opts { - kvs = append(kvs, opt.taskStartedAttr()) + if kv := opt.taskStartedAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } o.o.Observe(value, metric.WithAttributes(kvs...)) } diff --git a/templates/code/go/tests/metrics/expected/workermetric/metrics.go b/templates/code/go/tests/metrics/expected/workermetric/metrics.go index d4f6977..86c56bf 100644 --- a/templates/code/go/tests/metrics/expected/workermetric/metrics.go +++ b/templates/code/go/tests/metrics/expected/workermetric/metrics.go @@ -97,9 +97,13 @@ func (m TaskDuration) Record(ctx context.Context, value float64, myappTaskID mya return } kvs := make([]attribute.KeyValue, 0, 1+len(opts)) - kvs = append(kvs, myappTaskID.KeyValue()) + if kv := myappTaskID.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } for _, opt := range opts { - kvs = append(kvs, opt.taskDurationAttr()) + if kv := opt.taskDurationAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } m.inst.Record(ctx, value, metric.WithAttributes(kvs...)) } diff --git a/templates/code/go/tests/no_namespace/consumer/heartbeatspan/spans_test.go b/templates/code/go/tests/no_namespace/consumer/heartbeatspan/spans_test.go new file mode 100644 index 0000000..2e21a92 --- /dev/null +++ b/templates/code/go/tests/no_namespace/consumer/heartbeatspan/spans_test.go @@ -0,0 +1,38 @@ +package heartbeatspan_test + +import ( + "context" + "testing" + + "go.opentelemetry.io/otel/attribute" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + + "example.com/semconv/heartbeatattr" + "example.com/semconv/heartbeatspan" + "example.com/semconv/semconvtracer" +) + +func TestReadmeHeartbeatExampleCompilesAndRecords(t *testing.T) { + sr := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr)) + tracer := semconvtracer.New(tp, "test") + + ctx, span := heartbeatspan.Start(context.Background(), tracer, heartbeatspan.Name{}, heartbeatattr.NewSequence(1)) + _ = ctx + span.End() + + ended := sr.Ended() + if len(ended) != 1 { + t.Fatalf("len(Ended()) = %d, want 1", len(ended)) + } + got := ended[0] + if got.Name() != "heartbeat" { + t.Fatalf("Name() = %q, want %q", got.Name(), "heartbeat") + } + want := attribute.NewSet(heartbeatattr.NewSequence(1).KeyValue()) + gotAttrs := attribute.NewSet(got.Attributes()...) + if !gotAttrs.Equals(&want) { + t.Fatalf("Attributes() = %v, want %v", gotAttrs, want) + } +} diff --git a/templates/code/go/tests/no_namespace/expected/exceptionevent/events.go b/templates/code/go/tests/no_namespace/expected/exceptionevent/events.go index c7edba2..0dcdfb6 100644 --- a/templates/code/go/tests/no_namespace/expected/exceptionevent/events.go +++ b/templates/code/go/tests/no_namespace/expected/exceptionevent/events.go @@ -6,6 +6,7 @@ import ( "context" "time" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/log" "example.com/semconv/exceptionattr" @@ -44,7 +45,9 @@ func WithTimestamp(timestamp time.Time) Option { type bareEventMessage struct{ v exceptionattr.Message } func (o bareEventMessage) bareOption(record *log.Record) { - record.AddAttributes(o.v.KeyValue()) + if kv := o.v.KeyValue(); kv.Valid() { + record.AddAttributes(kv) + } } // WithMessage sets `exception.message` on the event. @@ -60,9 +63,11 @@ func Emit(ctx context.Context, logger semconvlogger.Logger, exceptionType except var record log.Record record.SetEventName("exception") record.SetSeverity(log.SeverityInfo) - record.AddAttributes( - exceptionType.KeyValue(), - ) + kvs := make([]attribute.KeyValue, 0, 1) + if kv := exceptionType.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } + record.AddAttributes(kvs...) for _, opt := range opts { opt.bareOption(&record) } diff --git a/templates/code/go/tests/no_namespace/expected/heartbeatspan/spans.go b/templates/code/go/tests/no_namespace/expected/heartbeatspan/spans.go index 974b986..7c48b24 100644 --- a/templates/code/go/tests/no_namespace/expected/heartbeatspan/spans.go +++ b/templates/code/go/tests/no_namespace/expected/heartbeatspan/spans.go @@ -73,7 +73,9 @@ func (s Span) End(opts ...trace.SpanEndOption) { func (s Span) SetAttributes(attrs ...Attr) { kvs := make([]attribute.KeyValue, 0, len(attrs)) for _, attr := range attrs { - kvs = append(kvs, attr.bareStartAttr()) + if kv := attr.bareStartAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } s.Span().SetAttributes(kvs...) } @@ -96,9 +98,13 @@ func Start(ctx context.Context, tracer semconvtracer.Tracer, name Name, heartbea name = Name{value: "heartbeat"} } kvs := make([]attribute.KeyValue, 0, 1+len(opts)) - kvs = append(kvs, heartbeatSequence.KeyValue()) + if kv := heartbeatSequence.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } for _, opt := range opts { - kvs = append(kvs, opt.bareStartAttr()) + if kv := opt.bareStartAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } ctx, span := tracer.TraceTracer().Start( ctx, diff --git a/templates/code/go/tests/no_namespace/expected/hostentity/entities.go b/templates/code/go/tests/no_namespace/expected/hostentity/entities.go index b1b9353..b8f5530 100644 --- a/templates/code/go/tests/no_namespace/expected/hostentity/entities.go +++ b/templates/code/go/tests/no_namespace/expected/hostentity/entities.go @@ -41,9 +41,13 @@ type Entity struct{ attrs []attribute.KeyValue } // options. func NewEntity(hostID hostattr.ID, opts ...EntityAttr) Entity { attrs := make([]attribute.KeyValue, 0, 1+len(opts)) - attrs = append(attrs, hostID.KeyValue()) + if kv := hostID.KeyValue(); kv.Valid() { + attrs = append(attrs, kv) + } for _, opt := range opts { - attrs = append(attrs, opt.bareEntityAttr()) + if kv := opt.bareEntityAttr(); kv.Valid() { + attrs = append(attrs, kv) + } } return Entity{attrs: attrs} } diff --git a/templates/code/go/tests/params/expected/billingmetric/metrics.go b/templates/code/go/tests/params/expected/billingmetric/metrics.go index fee4d35..d9d2125 100644 --- a/templates/code/go/tests/params/expected/billingmetric/metrics.go +++ b/templates/code/go/tests/params/expected/billingmetric/metrics.go @@ -91,9 +91,13 @@ func (m InvoiceAmount) Record(ctx context.Context, value float64, billingInvoice return } kvs := make([]attribute.KeyValue, 0, 1+len(opts)) - kvs = append(kvs, billingInvoiceID.KeyValue()) + if kv := billingInvoiceID.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } for _, opt := range opts { - kvs = append(kvs, opt.invoiceAmountAttr()) + if kv := opt.invoiceAmountAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } m.inst.Record(ctx, value, metric.WithAttributes(kvs...)) } diff --git a/templates/code/go/tests/root_package_from_import_path/expected/myappmetric/metrics.go b/templates/code/go/tests/root_package_from_import_path/expected/myappmetric/metrics.go index e6e9ae8..0c5e6fd 100644 --- a/templates/code/go/tests/root_package_from_import_path/expected/myappmetric/metrics.go +++ b/templates/code/go/tests/root_package_from_import_path/expected/myappmetric/metrics.go @@ -60,7 +60,9 @@ func (m TaskStarted) Add(ctx context.Context, incr int64, myappTaskState myappat return } kvs := make([]attribute.KeyValue, 0, 1) - kvs = append(kvs, myappTaskState.KeyValue()) + if kv := myappTaskState.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } m.inst.Add(ctx, incr, metric.WithAttributes(kvs...)) } @@ -77,7 +79,9 @@ func (o TaskStartedObserver) Observe(value int64, myappTaskState myappattr.TaskS return } kvs := make([]attribute.KeyValue, 0, 1) - kvs = append(kvs, myappTaskState.KeyValue()) + if kv := myappTaskState.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } o.o.Observe(value, metric.WithAttributes(kvs...)) } diff --git a/templates/code/go/tests/root_package_version_segment/expected/myappmetric/metrics.go b/templates/code/go/tests/root_package_version_segment/expected/myappmetric/metrics.go index 7f6562c..5c692ad 100644 --- a/templates/code/go/tests/root_package_version_segment/expected/myappmetric/metrics.go +++ b/templates/code/go/tests/root_package_version_segment/expected/myappmetric/metrics.go @@ -60,7 +60,9 @@ func (m TaskStarted) Add(ctx context.Context, incr int64, myappTaskState myappat return } kvs := make([]attribute.KeyValue, 0, 1) - kvs = append(kvs, myappTaskState.KeyValue()) + if kv := myappTaskState.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } m.inst.Add(ctx, incr, metric.WithAttributes(kvs...)) } @@ -77,7 +79,9 @@ func (o TaskStartedObserver) Observe(value int64, myappTaskState myappattr.TaskS return } kvs := make([]attribute.KeyValue, 0, 1) - kvs = append(kvs, myappTaskState.KeyValue()) + if kv := myappTaskState.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } o.o.Observe(value, metric.WithAttributes(kvs...)) } diff --git a/templates/code/go/tests/spans/compile-fail/raw_string_for_typed_attribute.expected-error.txt b/templates/code/go/tests/spans/compile-fail/raw_string_for_typed_attribute.expected-error.txt new file mode 100644 index 0000000..b0f55d4 --- /dev/null +++ b/templates/code/go/tests/spans/compile-fail/raw_string_for_typed_attribute.expected-error.txt @@ -0,0 +1 @@ +cannot use "running" (untyped string constant) as myappattr.TaskState value diff --git a/templates/code/go/tests/spans/compile-fail/raw_string_for_typed_attribute.go b/templates/code/go/tests/spans/compile-fail/raw_string_for_typed_attribute.go new file mode 100644 index 0000000..e3da1bf --- /dev/null +++ b/templates/code/go/tests/spans/compile-fail/raw_string_for_typed_attribute.go @@ -0,0 +1,13 @@ +package invalidconsumer + +import ( + "context" + + "example.com/semconv/myappspan" + "example.com/semconv/semconvtracer" +) + +func invalid() { + var tracer semconvtracer.Tracer + myappspan.StartTaskRun(context.Background(), tracer, myappspan.TaskRunName{}, "running") +} diff --git a/templates/code/go/tests/spans/compile-fail/sealed_option_interface.expected-error.txt b/templates/code/go/tests/spans/compile-fail/sealed_option_interface.expected-error.txt new file mode 100644 index 0000000..d12d206 --- /dev/null +++ b/templates/code/go/tests/spans/compile-fail/sealed_option_interface.expected-error.txt @@ -0,0 +1 @@ +does not implement myappspan.TaskRunStartAttr (unexported method taskRunStartAttr) diff --git a/templates/code/go/tests/spans/compile-fail/sealed_option_interface.go b/templates/code/go/tests/spans/compile-fail/sealed_option_interface.go new file mode 100644 index 0000000..dd5c9b1 --- /dev/null +++ b/templates/code/go/tests/spans/compile-fail/sealed_option_interface.go @@ -0,0 +1,13 @@ +package invalidconsumer + +import ( + "go.opentelemetry.io/otel/attribute" + + "example.com/semconv/myappspan" +) + +type fakeAttr struct{} + +func (fakeAttr) taskRunStartAttr() attribute.KeyValue { return attribute.KeyValue{} } + +var invalid myappspan.TaskRunStartAttr = fakeAttr{} diff --git a/templates/code/go/tests/spans/consumer/myappspan/spans_test.go b/templates/code/go/tests/spans/consumer/myappspan/spans_test.go new file mode 100644 index 0000000..added3f --- /dev/null +++ b/templates/code/go/tests/spans/consumer/myappspan/spans_test.go @@ -0,0 +1,102 @@ +package myappspan_test + +import ( + "context" + "testing" + + "go.opentelemetry.io/otel/attribute" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "go.opentelemetry.io/otel/trace" + + "example.com/semconv/myappattr" + "example.com/semconv/myappspan" + "example.com/semconv/semconvtracer" +) + +func newTracer(t *testing.T) (semconvtracer.Tracer, *tracetest.SpanRecorder) { + t.Helper() + sr := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr)) + return semconvtracer.New(tp, "test"), sr +} + +func TestStartTaskRunRecordsNameKindAndAttributes(t *testing.T) { + tracer, sr := newTracer(t) + + _, span := myappspan.StartTaskRun( + context.Background(), + tracer, + myappspan.NewTaskRunName("run t-1"), + myappattr.TaskStateDone, + myappspan.WithTaskRunTaskID(myappattr.NewTaskID("t-1")), + ) + span.End() + + ended := sr.Ended() + if len(ended) != 1 { + t.Fatalf("len(Ended()) = %d, want 1", len(ended)) + } + got := ended[0] + if got.Name() != "run t-1" { + t.Fatalf("Name() = %q, want %q", got.Name(), "run t-1") + } + if got.SpanKind() != trace.SpanKindInternal { + t.Fatalf("SpanKind() = %v, want %v", got.SpanKind(), trace.SpanKindInternal) + } + want := attribute.NewSet( + myappattr.TaskStateDone.KeyValue(), + myappattr.NewTaskID("t-1").KeyValue(), + ) + gotSet := attribute.NewSet(got.Attributes()...) + if !gotSet.Equals(&want) { + t.Fatalf("Attributes() = %v, want %v", gotSet, want) + } +} + +func TestStartTaskRunDropsZeroValueRequiredAttribute(t *testing.T) { + tracer, sr := newTracer(t) + + _, span := myappspan.StartTaskRun( + context.Background(), + tracer, + myappspan.NewTaskRunName("run t-2"), + myappattr.TaskState{}, + ) + span.End() + + ended := sr.Ended() + if len(ended) != 1 { + t.Fatalf("len(Ended()) = %d, want 1", len(ended)) + } + if attrs := ended[0].Attributes(); len(attrs) != 0 { + t.Fatalf("Attributes() = %v, want none, the zero TaskState must not be recorded", attrs) + } +} + +func TestSetAttributesAddsAttributeAfterStart(t *testing.T) { + tracer, sr := newTracer(t) + + _, span := myappspan.StartTaskRun( + context.Background(), + tracer, + myappspan.NewTaskRunName("run t-3"), + myappattr.TaskStateQueued, + ) + span.SetAttributes(myappspan.WithTaskRunTaskID(myappattr.NewTaskID("t-3"))) + span.End() + + ended := sr.Ended() + want := attribute.NewSet(myappattr.TaskStateQueued.KeyValue(), myappattr.NewTaskID("t-3").KeyValue()) + gotSet := attribute.NewSet(ended[0].Attributes()...) + if !gotSet.Equals(&want) { + t.Fatalf("Attributes() = %v, want %v", gotSet, want) + } +} + +func TestZeroSpanEndsWithoutPanic(t *testing.T) { + var span myappspan.TaskRun + span.End() + span.SetAttributes(myappspan.WithTaskRunTaskID(myappattr.NewTaskID("t-1"))) + span.SetStatus(0, "") +} diff --git a/templates/code/go/tests/spans/expected/myappattr/attributes.go b/templates/code/go/tests/spans/expected/myappattr/attributes.go index 604f7de..379fd34 100644 --- a/templates/code/go/tests/spans/expected/myappattr/attributes.go +++ b/templates/code/go/tests/spans/expected/myappattr/attributes.go @@ -79,7 +79,10 @@ const taskStateKey = attribute.Key("myapp.task.state") // TaskState is a value of `myapp.task.state`. // // Current state of the task. -type TaskState struct{ value string } +type TaskState struct { + value string + ok bool +} // Key is the attribute key, for filters and views that need it. func (TaskState) Key() attribute.Key { @@ -87,7 +90,14 @@ func (TaskState) Key() attribute.Key { } // KeyValue is the `myapp.task.state` attribute set to this value. +// +// The zero TaskState is not a valid value. KeyValue returns an invalid +// attribute.KeyValue for it, which the generated instruments, spans, events, +// and entities drop. func (v TaskState) KeyValue() attribute.KeyValue { + if !v.ok { + return attribute.KeyValue{} + } return taskStateKey.String(v.value) } @@ -95,10 +105,10 @@ var ( // TaskStateQueued is the "queued" member of `myapp.task.state`. // // The task is waiting to run. - TaskStateQueued = TaskState{value: "queued"} + TaskStateQueued = TaskState{value: "queued", ok: true} // TaskStateDone is the "done" member of `myapp.task.state`. // // The task finished. - TaskStateDone = TaskState{value: "done"} + TaskStateDone = TaskState{value: "done", ok: true} ) diff --git a/templates/code/go/tests/spans/expected/myappspan/spans.go b/templates/code/go/tests/spans/expected/myappspan/spans.go index 425d925..d1cb37e 100644 --- a/templates/code/go/tests/spans/expected/myappspan/spans.go +++ b/templates/code/go/tests/spans/expected/myappspan/spans.go @@ -158,7 +158,9 @@ func (s TaskDispatch) End(opts ...trace.SpanEndOption) { func (s TaskDispatch) SetAttributes(attrs ...TaskDispatchAttr) { kvs := make([]attribute.KeyValue, 0, len(attrs)) for _, attr := range attrs { - kvs = append(kvs, attr.taskDispatchStartAttr()) + if kv := attr.taskDispatchStartAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } s.Span().SetAttributes(kvs...) } @@ -184,10 +186,16 @@ func (s TaskDispatch) SetStatus(code codes.Code, description string) { // each placeholder replaced by the value of the attribute it names. func StartTaskDispatch(ctx context.Context, tracer semconvtracer.Tracer, workerHostName workerattr.HostName, workerHostPort workerattr.HostPort, opts ...TaskDispatchStartAttr) (context.Context, TaskDispatch) { kvs := make([]attribute.KeyValue, 0, 2+len(opts)) - kvs = append(kvs, workerHostName.KeyValue()) - kvs = append(kvs, workerHostPort.KeyValue()) + if kv := workerHostName.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } + if kv := workerHostPort.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } for _, opt := range opts { - kvs = append(kvs, opt.taskDispatchStartAttr()) + if kv := opt.taskDispatchStartAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } name := "dispatch " + workerHostName.KeyValue().Value.Emit() + ":" + workerHostPort.KeyValue().Value.Emit() ctx, span := tracer.TraceTracer().Start( @@ -261,7 +269,9 @@ func (s TaskRun) End(opts ...trace.SpanEndOption) { func (s TaskRun) SetAttributes(attrs ...TaskRunAttr) { kvs := make([]attribute.KeyValue, 0, len(attrs)) for _, attr := range attrs { - kvs = append(kvs, attr.taskRunStartAttr()) + if kv := attr.taskRunStartAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } s.Span().SetAttributes(kvs...) } @@ -285,9 +295,13 @@ func StartTaskRun(ctx context.Context, tracer semconvtracer.Tracer, name TaskRun name = TaskRunName{value: "myapp.task.run"} } kvs := make([]attribute.KeyValue, 0, 1+len(opts)) - kvs = append(kvs, myappTaskState.KeyValue()) + if kv := myappTaskState.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } for _, opt := range opts { - kvs = append(kvs, opt.taskRunStartAttr()) + if kv := opt.taskRunStartAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } ctx, span := tracer.TraceTracer().Start( ctx, diff --git a/templates/code/go/tests/spans/expected/workerspan/spans.go b/templates/code/go/tests/spans/expected/workerspan/spans.go index 46ee6bc..59c3155 100644 --- a/templates/code/go/tests/spans/expected/workerspan/spans.go +++ b/templates/code/go/tests/spans/expected/workerspan/spans.go @@ -78,7 +78,9 @@ func (s TaskDispatch) End(opts ...trace.SpanEndOption) { func (s TaskDispatch) SetAttributes(attrs ...TaskDispatchAttr) { kvs := make([]attribute.KeyValue, 0, len(attrs)) for _, attr := range attrs { - kvs = append(kvs, attr.taskDispatchStartAttr()) + if kv := attr.taskDispatchStartAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } s.Span().SetAttributes(kvs...) } @@ -104,11 +106,19 @@ func (s TaskDispatch) SetStatus(code codes.Code, description string) { // each placeholder replaced by the value of the attribute it names. func StartTaskDispatch(ctx context.Context, tracer semconvtracer.Tracer, myappTaskAttempt myappattr.TaskAttempt, workerHostName workerattr.HostName, workerHostPort workerattr.HostPort, opts ...TaskDispatchStartAttr) (context.Context, TaskDispatch) { kvs := make([]attribute.KeyValue, 0, 3+len(opts)) - kvs = append(kvs, myappTaskAttempt.KeyValue()) - kvs = append(kvs, workerHostName.KeyValue()) - kvs = append(kvs, workerHostPort.KeyValue()) + if kv := myappTaskAttempt.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } + if kv := workerHostName.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } + if kv := workerHostPort.KeyValue(); kv.Valid() { + kvs = append(kvs, kv) + } for _, opt := range opts { - kvs = append(kvs, opt.taskDispatchStartAttr()) + if kv := opt.taskDispatchStartAttr(); kv.Valid() { + kvs = append(kvs, kv) + } } name := "dispatch " + workerHostName.KeyValue().Value.Emit() + ":" + workerHostPort.KeyValue().Value.Emit() ctx, span := tracer.TraceTracer().Start( diff --git a/templates/code/go/weaver.yaml b/templates/code/go/weaver.yaml index 4d63118..030e236 100644 --- a/templates/code/go/weaver.yaml +++ b/templates/code/go/weaver.yaml @@ -336,7 +336,8 @@ templates: then error("attribute \(.key) has no name left after its namespace, so it cannot become a Go identifier") else . end) | group_by(.namespace) - | map({namespace: .[0].namespace, attributes: sort_by(.key)}) + | map({namespace: .[0].namespace, attributes: sort_by(.key), + has_enum: ([.[] | .type | type == "object"] | any)}) | map( ([.attributes[] | . as $attr | (.short_name | gsub("[._]"; "") | ascii_downcase) as $short | (["P:" + $short, "p:" + $short + "key"] @@ -782,7 +783,7 @@ templates: else . end ) | map({namespace: .[0].namespace, prefix: .[0].prefix, events: sort_by(.id), - uses_attributes: ([.[] | .attributes[] | select(.imported)] | length > 0), + uses_attributes: ([.[] | .attributes[] | select(.imported or .requirement_level == "required")] | length > 0), imports: ( .[0].namespace as $pkg | (([.[] | .attributes[] | select(.imported | not)