Instrumenting Go applications¶
Introduction¶
This tutorial demonstrates how to use tracing to monitor your applications. We will instrument an example application to measure timing of each operation along with some execution context. We will also record all errors that may happen during program execution.
Example application¶
Let's create a directory and initialize a Go module for our application:
mkdir go-example-app
cd go-example-app
go mod init go-example-app
Our example application makes an HTTP call to download information about user's IP address via ip2c.org API. The main.go
file looks like this:
package main
import (
"fmt"
"io/ioutil"
"log"
"net/http"
"strings"
)
func main() {
countryInfo, err := fetchCountryInfo()
if err != nil {
log.Print(err)
return
}
countryCode, countryName, err := parseCountryInfo(countryInfo)
if err != nil {
log.Print(err)
return
}
fmt.Println(countryCode, countryName)
}
func fetchCountryInfo() (string, error) {
resp, err := http.Get("https://ip2c.org/self")
if err != nil {
return "", err
}
defer resp.Body.Close()
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(b), nil
}
func parseCountryInfo(s string) (code, country string, _ error) {
parts := strings.Split(s, ";")
if len(parts) < 4 {
return "", "", fmt.Errorf("ip2c: can't parse response: %q", s)
}
return parts[1], parts[3], nil
}
Adding context.Context¶
Go uses context.Context to pass the active span from one function to another. Let's create a default context and accept it as a first arg in our functions:
diff --git a/main.go b/main.go
index 8cc2c64..1408398 100644
--- a/main.go
+++ b/main.go
@@ -1,6 +1,7 @@
package main
import (
+ "context"
"fmt"
"io/ioutil"
"log"
@@ -9,13 +10,15 @@ import (
)
func main() {
- countryInfo, err := fetchCountryInfo()
+ ctx := context.Background()
+
+ countryInfo, err := fetchCountryInfo(ctx)
if err != nil {
log.Print(err)
return
}
- countryCode, countryName, err := parseCountryInfo(countryInfo)
+ countryCode, countryName, err := parseCountryInfo(ctx, countryInfo)
if err != nil {
log.Print(err)
return
@@ -24,7 +27,7 @@ func main() {
fmt.Println(countryCode, countryName)
}
-func fetchCountryInfo() (string, error) {
+func fetchCountryInfo(ctx context.Context) (string, error) {
resp, err := http.Get("https://ip2c.org/self")
if err != nil {
return "", err
@@ -38,7 +41,7 @@ func fetchCountryInfo() (string, error) {
return string(b), nil
}
-func parseCountryInfo(s string) (code, country string, _ error) {
+func parseCountryInfo(ctx context.Context, s string) (code, country string, _ error) {
parts := strings.Split(s, ";")
if len(parts) < 4 {
return "", "", fmt.Errorf("ip2c: can't parse response: %q", s)
Creating a tracer¶
To monitor our program, we need to wrap (instrument) potentially interesting parts of the program with spans. You create spans with a tracer so we will need one. But first let's install Uptrace which comes with OpenTelemetry as a dependency:
go get github.com/uptrace/uptrace-go
Now we can create a named tracer go-example-app
:
import "go.opentelemetry.io/otel"
var tracer = otel.Tracer("go-example-app")
And Uptrace client that exports collected data to Uptrace:
import "github.com/uptrace/uptrace-go/uptrace"
upclient := uptrace.NewClient(&uptrace.Config{
// copy your project DSN here or use UPTRACE_DSN env var
DSN: "",
})
Instrumenting code with spans¶
We instrument code by creating a span at the start of each operation and calling span.End
at the end. To set active span, API accepts and returns context.Context
that we should use later to start other spans.
ctx, span := tracer.Start(ctx, "span-name")
// operation body
span.End()
Let's use that knowledge to instrument fetchCountryInfo
function and record execution context using span.SetAttributes
API:
diff --git a/main.go b/main.go
index ff4d7c8..930a90f 100644
--- a/main.go
+++ b/main.go
@@ -9,6 +9,7 @@ import (
"strings"
"go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/label"
)
var tracer = otel.Tracer("go-example-app")
@@ -32,6 +33,9 @@ func main() {
}
func fetchCountryInfo(ctx context.Context) (string, error) {
+ ctx, span := tracer.Start(ctx, "fetchCountryInfo")
+ defer span.End()
+
resp, err := http.Get("https://ip2c.org/self")
if err != nil {
return "", err
@@ -43,6 +47,11 @@ func fetchCountryInfo(ctx context.Context) (string, error) {
return "", err
}
+ span.SetAttributes(
+ label.String("ip", "self"),
+ label.Int("resp_len", len(b)),
+ )
+
return string(b), nil
}
Monitoring errors¶
To record errors, OpenTelemetry uses span events and provides span.RecordError
API:
if err != nil {
span.RecordError(ctx, err)
return err
}
You can also use some logging library like logrus and integrate it with OpenTelemetry:
if err != nil {
logrus.WithContext(ctx).WithError(err).Error("http.Get failed")
return err
}
Root span¶
After successfully instrumenting our 2 functions we need to tie them together into a single trace. We do that by creating a root span fetchCountry
for them. While we are at it, let's also record the errors.
diff --git a/main.go b/main.go
index 3973b46..2db8354 100644
--- a/main.go
+++ b/main.go
@@ -24,15 +23,18 @@ func main() {
ctx := context.Background()
+ ctx, span := tracer.Start(ctx, "fetchCountry")
+ defer span.End()
+
countryInfo, err := fetchCountryInfo(ctx)
if err != nil {
- log.Print(err)
+ span.RecordError(ctx, err)
return
}
countryCode, countryName, err := parseCountryInfo(ctx, countryInfo)
if err != nil {
- log.Print(err)
+ span.RecordError(ctx, err)
return
}
Putting all together¶
The resulting program is available at GitHub and looks like this:
package main
import (
"context"
"fmt"
"io/ioutil"
"net/http"
"strings"
"github.com/uptrace/uptrace-go/uptrace"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/label"
)
var tracer = otel.Tracer("go-example-app")
func main() {
upclient := uptrace.NewClient(&uptrace.Config{
// copy your project DSN here or use UPTRACE_DSN env var
DSN: "",
})
defer upclient.Close()
ctx := context.Background()
ctx, span := tracer.Start(ctx, "fetchCountry")
defer span.End()
countryInfo, err := fetchCountryInfo(ctx)
if err != nil {
span.RecordError(ctx, err)
return
}
countryCode, countryName, err := parseCountryInfo(ctx, countryInfo)
if err != nil {
span.RecordError(ctx, err)
return
}
span.SetAttributes(
label.String("country.code", countryCode),
label.String("country.name", countryName),
)
fmt.Println("trace URL", upclient.TraceURL(span))
}
func fetchCountryInfo(ctx context.Context) (string, error) {
ctx, span := tracer.Start(ctx, "fetchCountryInfo")
defer span.End()
resp, err := http.Get("https://ip2c.org/self")
if err != nil {
span.RecordError(ctx, err)
return "", err
}
defer resp.Body.Close()
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
span.RecordError(ctx, err)
return "", err
}
span.SetAttributes(
label.String("ip", "self"),
label.Int("resp_len", len(b)),
)
return string(b), nil
}
func parseCountryInfo(ctx context.Context, s string) (code, country string, _ error) {
ctx, span := tracer.Start(ctx, "parseCountryInfo")
defer span.End()
parts := strings.Split(s, ";")
if len(parts) < 4 {
err := fmt.Errorf("ip2c: can't parse response: %q", s)
span.RecordError(ctx, err)
return "", "", err
}
return parts[1], parts[3], nil
}
When run it produces the trace below. As expected app spends the majority of the time in fetchCountryInfo
function making an HTTP request.
Conclusion¶
That's all about instrumenting our simple program. For bigger apps with lots of traces Uptrace groups similar traces together and provides deep insights for each group of traces and even whole systems. Please check Uptrace playground for an example.