Skip to content

Add migration doc generation to semconvgen #6819

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
May 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ semconv-generate: $(SEMCONVKIT)
--param tag=$(TAG) \
go \
/home/weaver/target
$(SEMCONVKIT) -output "$(SEMCONVPKG)/$(TAG)" -tag "$(TAG)"
$(SEMCONVKIT) -semconv "$(SEMCONVPKG)" -tag "$(TAG)"

.PHONY: gorelease
gorelease: $(OTEL_GO_MOD_DIRS:%=gorelease/%)
Expand Down
1 change: 1 addition & 0 deletions internal/tools/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module go.opentelemetry.io/otel/internal/tools
go 1.23.0

require (
github.com/Masterminds/semver v1.5.0
github.com/client9/misspell v0.3.4
github.com/gogo/protobuf v1.3.2
github.com/golangci/golangci-lint/v2 v2.1.6
Expand Down
2 changes: 2 additions & 0 deletions internal/tools/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ github.com/Djarvur/go-err113 v0.1.0 h1:uCRZZOdMQ0TZPHYTdYpoC0bLYJKPEHPUJ8MeAa51l
github.com/Djarvur/go-err113 v0.1.0/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs=
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 h1:Sz1JIXEcSfhz7fUi7xHnhpIE0thVASYjvosApmHuD2k=
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1/go.mod h1:n/LSCXNuIYqVfBlVXyHfMQkZDdp1/mmxfSjADd3z1Zg=
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4=
github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
Expand Down
58 changes: 58 additions & 0 deletions internal/tools/semconvkit/decls/decls.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

// Package decls provides a set of functions to parse and analyze Go source
// code and get the declarations within it.
package decls // import "go.opentelemetry.io/otel/internal/tools/semconvkit/decls"

import (
"go/ast"
"go/parser"
"go/token"
"strings"
)

// GetNames parses the Go source code in the specified package path and returns
// the names extracted from the declarations using the provided parser
// function.
//
// The names are returned as a map where the keys are the names fully
// lowercased form of the name and the values are the original format of the
// name.
func GetNames(pkgPath string, f Parser) (Names, error) {
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, pkgPath, nil, 0)
if err != nil {
return nil, err
}

out := make(Names)
for _, pkg := range pkgs {
for _, file := range pkg.Files {
for _, decl := range file.Decls {
for _, name := range f(decl) {
out[NewCanonicalName(name)] = Name(name)
}
}
}
}
return out, nil
}

// Parser is a function type that takes an [ast.Decl] and returns a slice of
// parsed string identifiers.
type Parser func(ast.Decl) []string

// CanonicalName is the canonical form of a name (lowercase).
type CanonicalName string

// NewCanonicalName returns name as a [CanonicalName].
func NewCanonicalName(name string) CanonicalName {
return CanonicalName(strings.ToLower(name))
}

// Name is the original form of a name (case-sensitive).
type Name string

// Names is a map of canonical names to their original names.
type Names map[CanonicalName]Name
254 changes: 233 additions & 21 deletions internal/tools/semconvkit/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,47 +2,119 @@
// SPDX-License-Identifier: Apache-2.0

// Package semconvkit is used to generate opentelemetry-go specific semantic
// convention code. It is expected to be used in with the semconvgen utility
// (go.opentelemetry.io/build-tools/semconvgen) to completely generate
// versioned sub-packages of go.opentelemetry.io/otel/semconv.
// convention code.
package main

import (
"embed"
"errors"
"flag"
"log"
"fmt"
"go/ast"
"go/token"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"text/template"

"github.com/Masterminds/semver"
"go.opentelemetry.io/otel/internal/tools/semconvkit/decls"
)

var (
out = flag.String("output", "./", "output directory")
tag = flag.String("tag", "", "OpenTelemetry tagged version")
logLevel = flag.String("log-level", "", `Logging level ("debug", "info", "warn", "error")`)
semconvPkg = flag.String("semconv", "./", "semconv package directory")
tag = flag.String("tag", "", "OpenTelemetry tagged version")
prev = flag.String("prev", "", "previous semconv version")

//go:embed templates/*.tmpl
rootFS embed.FS
)

// SemanticConventions are information about the semantic conventions being
// generated.
type SemanticConventions struct {
// TagVer is the tagged version (i.e. v1.7.0 and not 1.7.0).
TagVer string
func main() {
flag.Parse()

slog.SetDefault(newLogger(*logLevel))

if *tag == "" {
slog.Error("invalid tag", "tag", *tag)
os.Exit(1)
}

sc := &SemanticConventions{TagVer: *tag}

out := filepath.Join(*semconvPkg, *tag)

// Render all other files before the MIGRATION file. That file needs the
// full package declaration so it can determine compatibility accurately.
entries, err := rootFS.ReadDir("templates")
if err != nil {
slog.Error("error reading templates", "err", err)
os.Exit(1)
}

for _, entry := range entries {
if entry.Name() == "MIGRATION.md.tmpl" {
continue
}

src := filepath.Join("templates", entry.Name())
err := render(src, out, sc)
if err != nil {
slog.Error("error rendering template", "err", err, "template", entry.Name())
os.Exit(1)
}
}

prevPkg, err := prevVer(*semconvPkg, *tag, *prev)
if err != nil {
slog.Error("previous version not found, skipping migration", "err", err)
os.Exit(1)
}

slog.Debug("previous version found", "prev", prevPkg)
m, err := newMigration(out, filepath.Join(*semconvPkg, prevPkg))
if err != nil {
slog.Error("error getting migration, skipping", "err", err)
os.Exit(1)
}

if err := render("templates/MIGRATION.md.tmpl", out, m); err != nil {
slog.Error("error rendering migration template", "err", err)
os.Exit(1)
}
}

func (sc SemanticConventions) SemVer() string {
return strings.TrimPrefix(*tag, "v")
func newLogger(lvlStr string) *slog.Logger {
levelVar := new(slog.LevelVar) // Default value of info.
opts := &slog.HandlerOptions{AddSource: true, Level: levelVar}
h := slog.NewTextHandler(os.Stderr, opts)
logger := slog.New(h)

if lvlStr == "" {
return logger
}

var level slog.Level
if err := level.UnmarshalText([]byte(lvlStr)); err != nil {
logger.Error("failed to parse log level", "error", err, "log-level", lvlStr)
} else {
levelVar.Set(level)
}

return logger
}

// render renders all templates to the dest directory using the data.
func render(src, dest string, data *SemanticConventions) error {
func render(src, dest string, data any) error {
tmpls, err := template.ParseFS(rootFS, src)
if err != nil {
return err
}
for _, tmpl := range tmpls.Templates() {
slog.Debug("rendering template", "name", tmpl.Name())
target := filepath.Join(dest, strings.TrimSuffix(tmpl.Name(), ".tmpl"))
wr, err := os.Create(target)
if err != nil {
Expand All @@ -58,16 +130,156 @@ func render(src, dest string, data *SemanticConventions) error {
return nil
}

func main() {
flag.Parse()
// prevVer returns the previous version of the semantic conventions package.
// It will first check for hint within root and return that value if found. If
// not found, it will find all directories in root with a version name and
// return the version that is less than and closest to the curr version.
func prevVer(root, cur, hint string) (string, error) {
slog.Debug("prevVer", "root", root, "current", cur, "hint", hint)
info, err := os.Stat(root)
if err != nil {
return "", fmt.Errorf("root directory %q not found: %w", root, err)
}
if !info.IsDir() {
return "", fmt.Errorf("root %q is not a directory", root)
}

if *tag == "" {
log.Fatalf("invalid tag: %q", *tag)
if hint != "" {
sub := filepath.Join(root, hint)
slog.Debug("looking for hint", "path", sub)
info, err = os.Stat(sub)
if err == nil && info.IsDir() {
return hint, nil
}
}

sc := &SemanticConventions{TagVer: *tag}
v, err := semver.NewVersion(cur)
if err != nil {
return "", fmt.Errorf("invalid current version %q: %w", cur, err)
}

if err := render("templates/*.tmpl", *out, sc); err != nil {
log.Fatal(err)
entries, err := os.ReadDir(root)
if err != nil {
return "", fmt.Errorf("error reading root %q: %w", root, err)
}

var prev *semver.Version
for _, entry := range entries {
slog.Debug("root entry", "name", entry.Name())
if !entry.IsDir() {
continue
}

ver, err := semver.NewVersion(entry.Name())
if err != nil {
slog.Debug("not a version dir", "name", entry.Name())
// Ignore errors for non-semver directories.
continue
}
slog.Debug("found version dir", "prev", ver)
if ver.LessThan(v) && (prev == nil || ver.GreaterThan(prev)) {
slog.Debug("new previous version", "version", ver)
prev = ver
}
}

if prev == nil {
return "", errors.New("no previous version found")
}
return prev.Original(), nil
}

// SemanticConventions are information about the semantic conventions being
// generated.
type SemanticConventions struct {
// TagVer is the tagged version (i.e. v.7.0 and not 1.7.0).
TagVer string
}

func (sc SemanticConventions) SemVer() string {
return strings.TrimPrefix(*tag, "v")
}

// Migration contains the details about the migration from the previous
// semantic conventions to the current one.
type Migration struct {
CurVer string
PrevVer string
Removals []string
Renames []Rename
}

// Remove is a semantic convention declaration that has been renamed.
type Rename struct {
Old, New string
}

func newMigration(cur, prev string) (*Migration, error) {
cDecl, err := decls.GetNames(cur, parse)
if err != nil {
return nil, fmt.Errorf("error parsing current version %q: %w", cur, err)
}

pDecl, err := decls.GetNames(prev, parse)
if err != nil {
return nil, fmt.Errorf("error parsing previous version %q: %w", prev, err)
}

m := Migration{
CurVer: filepath.Base(cur),
PrevVer: filepath.Base(prev),
Removals: inAnotB(pDecl, cDecl),
Renames: renames(pDecl, cDecl),
}

sort.Strings(m.Removals)
sort.Slice(m.Renames, func(i, j int) bool {
return m.Renames[i].Old < m.Renames[j].Old
})

return &m, nil
}

func parse(d ast.Decl) []string {
var out []string
switch decl := d.(type) {
case *ast.FuncDecl:
out = []string{decl.Name.Name}
case *ast.GenDecl:
if decl.Tok == token.CONST || decl.Tok == token.VAR {
for _, spec := range decl.Specs {
if valueSpec, ok := spec.(*ast.ValueSpec); ok {
for _, name := range valueSpec.Names {
out = append(out, name.Name)
}
}
}
}
}
return out
}

// inAnotB returns the canonical names in a that are not in b.
func inAnotB(a, b decls.Names) []string {
var diff []string
for key, name := range a {
if _, ok := b[key]; !ok {
diff = append(diff, string(name))
}
}
return diff
}

// renames returns the renames between the old and current names.
func renames(old, current decls.Names) []Rename {
var renames []Rename
for key, name := range old {
if otherName, ok := current[key]; ok && name != otherName {
renames = append(renames, Rename{
Old: string(name),
New: string(otherName),
})
}
}
return renames
}
Loading