Skip to content

Commit 34d21b4

Browse files
trixnzjjti
andauthored
Add support for "Start" utility methods (#16)
* feat: replace hardcoded span start function names with regex patterns Replaces the hardcoded list of function names and packages with a list of regex patterns that may be extended using the `-extra-start-span-signatures` command line option. The format is `<regex>:<telemetry-type>`. Ideally this would just be a list of regex patterns, but we need to know what telemetry library we're matching so we don't run RecordError on opencensus. * tests: update tests to use new default config The tests need to unconditionally include the DefaultStartSpanSignatures, which was not possible with the old harness. Instead of returning an object directly, they now return a function to prepare a config, which ensures they all start out with the default config as a base. * Add test, skip validating spans within starter funcs * Hack: fix check for start func method name * fix: increase robustness of skipping custom matchers Instead of checking if the function name matches, which might result in overzealous exclusions, prefer to match against the function signature itself * docs: document new behaviour * Remove sig split in config * nit: small wording changes --------- Co-authored-by: josh <[email protected]>
1 parent 41d395c commit 34d21b4

File tree

7 files changed

+219
-48
lines changed

7 files changed

+219
-48
lines changed

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,13 @@ linters-settings:
6363
# Default: []
6464
ignore-check-signatures:
6565
- "telemetry.RecordError"
66+
# A list of regexes for additional function signatures that create spans. This is useful if you have a utility
67+
# method to create spans. Each entry should be of the form <regex>:<telemetry-type>, where `telemetry-type`
68+
# can be `opentelemetry` or `opencensus`.
69+
# https://github.com/jjti/go-spancheck#extra-start-span-signatures
70+
# Default: []
71+
extra-start-span-signatures:
72+
- "github.com/user/repo/telemetry/trace.Start:opentelemetry"
6673
```
6774
6875
### CLI
@@ -123,6 +130,21 @@ The warnings are can be ignored by setting `-ignore-check-signatures` flag to `r
123130
spancheck -checks 'end,set-status,record-error' -ignore-check-signatures 'recordErr' ./...
124131
```
125132

133+
### Extra Start Span Signatures
134+
135+
By default, Span creation will be tracked from calls to [(go.opentelemetry.io/otel/trace.Tracer).Start](https://github.com/open-telemetry/opentelemetry-go/blob/98b32a6c3a87fbee5d34c063b9096f416b250897/trace/trace.go#L523), [go.opencensus.io/trace.StartSpan](https://pkg.go.dev/go.opencensus.io/trace#StartSpan), or [go.opencensus.io/trace.StartSpanWithRemoteParent](https://github.com/census-instrumentation/opencensus-go/blob/v0.24.0/trace/trace_api.go#L66).
136+
137+
You can use the `-extra-start-span-signatures` flag to list additional Span creation functions. For all such functions:
138+
139+
1. their Spans will be linted (for all enable checks)
140+
1. checks will be disabled (i.e. there is no linting of Spans within the creation functions)
141+
142+
You must pass a comma-separated list of regex patterns and the telemetry library corresponding to the returned Span. Each entry should be of the form `<regex>:<telemetry-type>`, where `telemetry-type` can be `opentelemetry` or `opencensus`. For example, if you have created a function named `StartTrace` in a `telemetry` package, using the `go.opentelemetry.io/otel` library, you can include this function for analysis like so:
143+
144+
```bash
145+
spancheck -extra-start-span-signatures 'github.com/user/repo/telemetry/StartTrace:opentelemetry' ./...
146+
```
147+
126148
## Problem Statement
127149

128150
Tracing is a celebrated [[1](https://andydote.co.uk/2023/09/19/tracing-is-better/),[2](https://charity.wtf/2022/08/15/live-your-best-life-with-structured-events/)] and well marketed [[3](https://docs.datadoghq.com/tracing/),[4](https://www.honeycomb.io/distributed-tracing)] pillar of observability. But self-instrumented tracing requires a lot of easy-to-forget boilerplate:

cmd/spancheck/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,17 @@ func main() {
2323
// Set the list of function signatures to ignore checks for.
2424
ignoreCheckSignatures := ""
2525
flag.StringVar(&ignoreCheckSignatures, "ignore-check-signatures", "", "comma-separated list of regex for function signatures that disable checks on errors")
26+
27+
extraStartSpanSignatures := ""
28+
flag.StringVar(&extraStartSpanSignatures, "extra-start-span-signatures", "", "comma-separated list of regex:telemetry-type for function signatures that indicate the start of a span")
29+
2630
flag.Parse()
2731

2832
cfg := spancheck.NewDefaultConfig()
2933
cfg.EnabledChecks = strings.Split(checkStrings, ",")
3034
cfg.IgnoreChecksSignaturesSlice = strings.Split(ignoreCheckSignatures, ",")
3135

36+
cfg.StartSpanMatchersSlice = append(cfg.StartSpanMatchersSlice, strings.Split(extraStartSpanSignatures, ",")...)
37+
3238
singlechecker.Main(spancheck.NewAnalyzerWithConfig(cfg))
3339
}

config.go

Lines changed: 91 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@ const (
2222
RecordErrorCheck
2323
)
2424

25+
var (
26+
startSpanSignatureCols = 2
27+
defaultStartSpanSignatures = []string{
28+
// https://github.com/open-telemetry/opentelemetry-go/blob/98b32a6c3a87fbee5d34c063b9096f416b250897/trace/trace.go#L523
29+
`\(go.opentelemetry.io/otel/trace.Tracer\).Start:opentelemetry`,
30+
// https://pkg.go.dev/go.opencensus.io/trace#StartSpan
31+
`go.opencensus.io/trace.StartSpan:opencensus`,
32+
// https://github.com/census-instrumentation/opencensus-go/blob/v0.24.0/trace/trace_api.go#L66
33+
`go.opencensus.io/trace.StartSpanWithRemoteParent:opencensus`,
34+
}
35+
)
36+
2537
func (c Check) String() string {
2638
switch c {
2739
case EndCheck:
@@ -35,14 +47,17 @@ func (c Check) String() string {
3547
}
3648
}
3749

38-
var (
39-
// Checks is a list of all checks by name.
40-
Checks = map[string]Check{
41-
EndCheck.String(): EndCheck,
42-
SetStatusCheck.String(): SetStatusCheck,
43-
RecordErrorCheck.String(): RecordErrorCheck,
44-
}
45-
)
50+
// Checks is a list of all checks by name.
51+
var Checks = map[string]Check{
52+
EndCheck.String(): EndCheck,
53+
SetStatusCheck.String(): SetStatusCheck,
54+
RecordErrorCheck.String(): RecordErrorCheck,
55+
}
56+
57+
type spanStartMatcher struct {
58+
signature *regexp.Regexp
59+
spanType spanType
60+
}
4661

4762
// Config is a configuration for the spancheck analyzer.
4863
type Config struct {
@@ -55,19 +70,25 @@ type Config struct {
5570
// the IgnoreSetStatusCheckSignatures regex.
5671
IgnoreChecksSignaturesSlice []string
5772

73+
StartSpanMatchersSlice []string
74+
5875
endCheckEnabled bool
5976
setStatusEnabled bool
6077
recordErrorEnabled bool
6178

6279
// ignoreChecksSignatures is a regex that, if matched, disables the
6380
// SetStatus and RecordError checks on error.
6481
ignoreChecksSignatures *regexp.Regexp
82+
83+
startSpanMatchers []spanStartMatcher
84+
startSpanMatchersCustomRegex *regexp.Regexp
6585
}
6686

6787
// NewDefaultConfig returns a new Config with default values.
6888
func NewDefaultConfig() *Config {
6989
return &Config{
70-
EnabledChecks: []string{EndCheck.String()},
90+
EnabledChecks: []string{EndCheck.String()},
91+
StartSpanMatchersSlice: defaultStartSpanSignatures,
7192
}
7293
}
7394

@@ -83,6 +104,11 @@ func (c *Config) finalize() {
83104

84105
// parseSignatures sets the Ignore*CheckSignatures regex from the string slices.
85106
func (c *Config) parseSignatures() {
107+
c.parseIgnoreSignatures()
108+
c.parseStartSpanSignatures()
109+
}
110+
111+
func (c *Config) parseIgnoreSignatures() {
86112
if c.ignoreChecksSignatures == nil && len(c.IgnoreChecksSignaturesSlice) > 0 {
87113
if len(c.IgnoreChecksSignaturesSlice) == 1 && c.IgnoreChecksSignaturesSlice[0] == "" {
88114
return
@@ -92,6 +118,62 @@ func (c *Config) parseSignatures() {
92118
}
93119
}
94120

121+
func (c *Config) parseStartSpanSignatures() {
122+
if c.startSpanMatchers != nil {
123+
return
124+
}
125+
126+
customMatchers := []string{}
127+
for i, sig := range c.StartSpanMatchersSlice {
128+
parts := strings.Split(sig, ":")
129+
130+
// Make sure we have both a signature and a telemetry type
131+
if len(parts) != startSpanSignatureCols {
132+
log.Default().Printf("[WARN] invalid start span signature \"%s\". expected regex:telemetry-type\n", sig)
133+
134+
continue
135+
}
136+
137+
sig, sigType := parts[0], parts[1]
138+
if len(sig) < 1 {
139+
log.Default().Print("[WARN] invalid start span signature, empty pattern")
140+
141+
continue
142+
}
143+
144+
spanType, ok := SpanTypes[sigType]
145+
if !ok {
146+
validSpanTypes := make([]string, 0, len(SpanTypes))
147+
for k := range SpanTypes {
148+
validSpanTypes = append(validSpanTypes, k)
149+
}
150+
151+
log.Default().
152+
Printf("[WARN] invalid start span type \"%s\". expected one of %s\n", sigType, strings.Join(validSpanTypes, ", "))
153+
154+
continue
155+
}
156+
157+
regex, err := regexp.Compile(sig)
158+
if err != nil {
159+
log.Default().Printf("[WARN] failed to compile regex from signature %s: %v\n", sig, err)
160+
161+
continue
162+
}
163+
164+
c.startSpanMatchers = append(c.startSpanMatchers, spanStartMatcher{
165+
signature: regex,
166+
spanType: spanType,
167+
})
168+
169+
if i >= len(defaultStartSpanSignatures) {
170+
customMatchers = append(customMatchers, sig)
171+
}
172+
}
173+
174+
c.startSpanMatchersCustomRegex = createRegex(customMatchers)
175+
}
176+
95177
func parseChecks(checksSlice []string) []Check {
96178
if len(checksSlice) == 0 {
97179
return nil

spancheck.go

Lines changed: 54 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,15 @@ const (
2323
spanOpenCensus // from go.opencensus.io/trace
2424
)
2525

26-
var (
27-
// this approach stolen from errcheck
28-
// https://github.com/kisielk/errcheck/blob/7f94c385d0116ccc421fbb4709e4a484d98325ee/errcheck/errcheck.go#L22
29-
errorType = types.Universe.Lookup("error").Type().Underlying().(*types.Interface)
30-
)
26+
// SpanTypes is a list of all span types by name.
27+
var SpanTypes = map[string]spanType{
28+
"opentelemetry": spanOpenTelemetry,
29+
"opencensus": spanOpenCensus,
30+
}
31+
32+
// this approach stolen from errcheck
33+
// https://github.com/kisielk/errcheck/blob/7f94c385d0116ccc421fbb4709e4a484d98325ee/errcheck/errcheck.go#L22
34+
var errorType = types.Universe.Lookup("error").Type().Underlying().(*types.Interface)
3135

3236
// NewAnalyzerWithConfig returns a new analyzer configured with the Config passed in.
3337
// Its config can be set for testing.
@@ -84,6 +88,12 @@ func runFunc(pass *analysis.Pass, node ast.Node, config *Config) {
8488
funcScope = pass.TypesInfo.Scopes[v.Type]
8589
case *ast.FuncDecl:
8690
funcScope = pass.TypesInfo.Scopes[v.Type]
91+
fnSig := pass.TypesInfo.ObjectOf(v.Name).String()
92+
93+
// Skip checking spans in this function if it's a custom starter/creator.
94+
if config.startSpanMatchersCustomRegex != nil && config.startSpanMatchersCustomRegex.MatchString(fnSig) {
95+
return
96+
}
8797
}
8898

8999
// Maps each span variable to its defining ValueSpec/AssignStmt.
@@ -108,8 +118,12 @@ func runFunc(pass *analysis.Pass, node ast.Node, config *Config) {
108118
// ctx, span := otel.Tracer("app").Start(...)
109119
// ctx, span = otel.Tracer("app").Start(...)
110120
// var ctx, span = otel.Tracer("app").Start(...)
111-
sType, sStart := isSpanStart(pass.TypesInfo, n)
112-
if !sStart || !isCall(stack[len(stack)-2]) {
121+
sType, isStart := isSpanStart(pass.TypesInfo, n, config.startSpanMatchers)
122+
if !isStart {
123+
return true
124+
}
125+
126+
if !isCall(stack[len(stack)-2]) {
113127
return true
114128
}
115129

@@ -169,23 +183,23 @@ func runFunc(pass *analysis.Pass, node ast.Node, config *Config) {
169183
for _, sv := range spanVars {
170184
if config.endCheckEnabled {
171185
// Check if there's no End to the span.
172-
if ret := getMissingSpanCalls(pass, g, sv, "End", func(pass *analysis.Pass, ret *ast.ReturnStmt) *ast.ReturnStmt { return ret }, nil); ret != nil {
186+
if ret := getMissingSpanCalls(pass, g, sv, "End", func(_ *analysis.Pass, ret *ast.ReturnStmt) *ast.ReturnStmt { return ret }, nil, config.startSpanMatchers); ret != nil {
173187
pass.ReportRangef(sv.stmt, "%s.End is not called on all paths, possible memory leak", sv.vr.Name())
174188
pass.ReportRangef(ret, "return can be reached without calling %s.End", sv.vr.Name())
175189
}
176190
}
177191

178192
if config.setStatusEnabled {
179193
// Check if there's no SetStatus to the span setting an error.
180-
if ret := getMissingSpanCalls(pass, g, sv, "SetStatus", getErrorReturn, config.ignoreChecksSignatures); ret != nil {
194+
if ret := getMissingSpanCalls(pass, g, sv, "SetStatus", getErrorReturn, config.ignoreChecksSignatures, config.startSpanMatchers); ret != nil {
181195
pass.ReportRangef(sv.stmt, "%s.SetStatus is not called on all paths", sv.vr.Name())
182196
pass.ReportRangef(ret, "return can be reached without calling %s.SetStatus", sv.vr.Name())
183197
}
184198
}
185199

186200
if config.recordErrorEnabled && sv.spanType == spanOpenTelemetry { // RecordError only exists in OpenTelemetry
187201
// Check if there's no RecordError to the span setting an error.
188-
if ret := getMissingSpanCalls(pass, g, sv, "RecordError", getErrorReturn, config.ignoreChecksSignatures); ret != nil {
202+
if ret := getMissingSpanCalls(pass, g, sv, "RecordError", getErrorReturn, config.ignoreChecksSignatures, config.startSpanMatchers); ret != nil {
189203
pass.ReportRangef(sv.stmt, "%s.RecordError is not called on all paths", sv.vr.Name())
190204
pass.ReportRangef(ret, "return can be reached without calling %s.RecordError", sv.vr.Name())
191205
}
@@ -194,25 +208,22 @@ func runFunc(pass *analysis.Pass, node ast.Node, config *Config) {
194208
}
195209

196210
// isSpanStart reports whether n is tracer.Start()
197-
func isSpanStart(info *types.Info, n ast.Node) (spanType, bool) {
211+
func isSpanStart(info *types.Info, n ast.Node, startSpanMatchers []spanStartMatcher) (spanType, bool) {
198212
sel, ok := n.(*ast.SelectorExpr)
199213
if !ok {
200214
return spanUnset, false
201215
}
202216

203-
switch sel.Sel.Name {
204-
case "Start": // https://github.com/open-telemetry/opentelemetry-go/blob/98b32a6c3a87fbee5d34c063b9096f416b250897/trace/trace.go#L523
205-
obj, ok := info.Uses[sel.Sel]
206-
return spanOpenTelemetry, ok && obj.Pkg().Path() == "go.opentelemetry.io/otel/trace"
207-
case "StartSpan": // https://pkg.go.dev/go.opencensus.io/trace#StartSpan
208-
obj, ok := info.Uses[sel.Sel]
209-
return spanOpenCensus, ok && obj.Pkg().Path() == "go.opencensus.io/trace"
210-
case "StartSpanWithRemoteParent": // https://github.com/census-instrumentation/opencensus-go/blob/v0.24.0/trace/trace_api.go#L66
211-
obj, ok := info.Uses[sel.Sel]
212-
return spanOpenCensus, ok && obj.Pkg().Path() == "go.opencensus.io/trace"
213-
default:
214-
return spanUnset, false
217+
fnSig := info.ObjectOf(sel.Sel).String()
218+
219+
// Check if the function is a span start function
220+
for _, matcher := range startSpanMatchers {
221+
if matcher.signature.MatchString(fnSig) {
222+
return matcher.spanType, true
223+
}
215224
}
225+
226+
return 0, false
216227
}
217228

218229
func isCall(n ast.Node) bool {
@@ -225,11 +236,16 @@ func getID(node ast.Node) *ast.Ident {
225236
case *ast.ValueSpec:
226237
if len(stmt.Names) > 1 {
227238
return stmt.Names[1]
239+
} else if len(stmt.Names) == 1 {
240+
return stmt.Names[0]
228241
}
229242
case *ast.AssignStmt:
230243
if len(stmt.Lhs) > 1 {
231244
id, _ := stmt.Lhs[1].(*ast.Ident)
232245
return id
246+
} else if len(stmt.Lhs) == 1 {
247+
id, _ := stmt.Lhs[0].(*ast.Ident)
248+
return id
233249
}
234250
}
235251
return nil
@@ -244,13 +260,14 @@ func getMissingSpanCalls(
244260
selName string,
245261
checkErr func(pass *analysis.Pass, ret *ast.ReturnStmt) *ast.ReturnStmt,
246262
ignoreCheckSig *regexp.Regexp,
263+
spanStartMatchers []spanStartMatcher,
247264
) *ast.ReturnStmt {
248265
// blockUses computes "uses" for each block, caching the result.
249266
memo := make(map[*cfg.Block]bool)
250267
blockUses := func(pass *analysis.Pass, b *cfg.Block) bool {
251268
res, ok := memo[b]
252269
if !ok {
253-
res = usesCall(pass, b.Nodes, sv, selName, ignoreCheckSig, 0)
270+
res = usesCall(pass, b.Nodes, sv, selName, ignoreCheckSig, spanStartMatchers, 0)
254271
memo[b] = res
255272
}
256273
return res
@@ -272,7 +289,7 @@ outer:
272289
}
273290

274291
// Is the call "used" in the remainder of its defining block?
275-
if usesCall(pass, rest, sv, selName, ignoreCheckSig, 0) {
292+
if usesCall(pass, rest, sv, selName, ignoreCheckSig, spanStartMatchers, 0) {
276293
return nil
277294
}
278295

@@ -314,7 +331,15 @@ outer:
314331
}
315332

316333
// usesCall reports whether stmts contain a use of the selName call on variable v.
317-
func usesCall(pass *analysis.Pass, stmts []ast.Node, sv spanVar, selName string, ignoreCheckSig *regexp.Regexp, depth int) bool {
334+
func usesCall(
335+
pass *analysis.Pass,
336+
stmts []ast.Node,
337+
sv spanVar,
338+
selName string,
339+
ignoreCheckSig *regexp.Regexp,
340+
startSpanMatchers []spanStartMatcher,
341+
depth int,
342+
) bool {
318343
if depth > 1 { // for perf reasons, do not dive too deep thru func literals, just one level deep check.
319344
return false
320345
}
@@ -329,7 +354,7 @@ func usesCall(pass *analysis.Pass, stmts []ast.Node, sv spanVar, selName string,
329354
cfgs := pass.ResultOf[ctrlflow.Analyzer].(*ctrlflow.CFGs)
330355
g := cfgs.FuncLit(n)
331356
if g != nil && len(g.Blocks) > 0 {
332-
return usesCall(pass, g.Blocks[0].Nodes, sv, selName, ignoreCheckSig, depth+1)
357+
return usesCall(pass, g.Blocks[0].Nodes, sv, selName, ignoreCheckSig, startSpanMatchers, depth+1)
333358
}
334359

335360
return false
@@ -352,8 +377,8 @@ func usesCall(pass *analysis.Pass, stmts []ast.Node, sv spanVar, selName string,
352377
stack = append(stack, n) // push
353378

354379
// Check whether the span was assigned over top of its old value.
355-
_, spanStart := isSpanStart(pass.TypesInfo, n)
356-
if spanStart {
380+
_, isStart := isSpanStart(pass.TypesInfo, n, startSpanMatchers)
381+
if isStart {
357382
if id := getID(stack[len(stack)-3]); id != nil && id.Obj.Decl == sv.id.Obj.Decl {
358383
reAssigned = true
359384
return false

0 commit comments

Comments
 (0)