Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
428 lines
9.9 KiB
Go
428 lines
9.9 KiB
Go
// Copyright © 2023 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package metricsx
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"connectrpc.com/connect"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/metadata"
|
|
"google.golang.org/grpc/status"
|
|
|
|
"github.com/gofrs/uuid"
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/ory/x/cmdx"
|
|
"github.com/ory/x/configx"
|
|
"github.com/ory/x/httpx"
|
|
"github.com/ory/x/logrusx"
|
|
"github.com/ory/x/resilience"
|
|
"github.com/ory/x/urlx"
|
|
|
|
"github.com/ory/analytics-go/v5"
|
|
)
|
|
|
|
const (
|
|
XForwardedHostHeader = "X-Forwarded-Host"
|
|
AuthorityHeader = ":authority"
|
|
)
|
|
|
|
var (
|
|
instance *Service
|
|
lock sync.Mutex
|
|
knownHeaders = []string{AuthorityHeader, XForwardedHostHeader}
|
|
)
|
|
|
|
// Service helps with providing context on metrics.
|
|
type Service struct {
|
|
optOut bool
|
|
instanceId string
|
|
|
|
o *Options
|
|
|
|
c analytics.Client
|
|
l *logrusx.Logger
|
|
|
|
mem *MemoryStatistics
|
|
}
|
|
|
|
// Hash returns a hashed string of the value.
|
|
func Hash(value string) string {
|
|
sha := sha256.Sum256([]byte(value))
|
|
return hex.EncodeToString(sha[:])
|
|
}
|
|
|
|
// Options configures the metrics service.
|
|
type Options struct {
|
|
// Service represents the service name, for example "ory-hydra".
|
|
Service string
|
|
|
|
// DeploymentId represents the cluster id, typically a hash of some unique configuration properties.
|
|
DeploymentId string
|
|
|
|
// DBDialect specifies the database dialect in use (e.g., "postgres", "mysql", "sqlite").
|
|
DBDialect string
|
|
|
|
// When this instance was started
|
|
StartTime time.Time
|
|
|
|
// IsDevelopment should be true if we assume that we're in a development environment.
|
|
IsDevelopment bool
|
|
|
|
// WriteKey is the segment API key.
|
|
WriteKey string
|
|
|
|
// WhitelistedPaths represents a list of paths that can be transmitted in clear text to segment.
|
|
WhitelistedPaths []string
|
|
|
|
// BuildVersion represents the build version.
|
|
BuildVersion string
|
|
|
|
// BuildHash represents the build git hash.
|
|
BuildHash string
|
|
|
|
// BuildTime represents the build time.
|
|
BuildTime string
|
|
|
|
// Hostname is a public URL configured for the service, used to derive hosted name for telemetry.
|
|
Hostname string
|
|
|
|
// Config overrides the analytics.Config. If nil, sensible defaults will be used.
|
|
Config *analytics.Config
|
|
|
|
// MemoryInterval sets how often memory statistics should be transmitted. Defaults to every 12 hours.
|
|
MemoryInterval time.Duration
|
|
}
|
|
|
|
type void struct{}
|
|
|
|
func (v *void) Logf(format string, args ...interface{}) {
|
|
}
|
|
|
|
func (v *void) Errorf(format string, args ...interface{}) {
|
|
}
|
|
|
|
// New returns a new metrics service. If one has been instantiated already, no new instance will be created.
|
|
func New(
|
|
cmd *cobra.Command,
|
|
l *logrusx.Logger,
|
|
c *configx.Provider,
|
|
o *Options,
|
|
) *Service {
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
|
|
if instance != nil {
|
|
return instance
|
|
}
|
|
|
|
o.StartTime = time.Now()
|
|
|
|
if o.BuildTime == "" {
|
|
o.BuildTime = "unknown"
|
|
}
|
|
|
|
if o.BuildVersion == "" {
|
|
o.BuildVersion = "unknown"
|
|
}
|
|
|
|
if o.BuildHash == "" {
|
|
o.BuildHash = "unknown"
|
|
}
|
|
|
|
if o.Config == nil {
|
|
o.Config = &analytics.Config{
|
|
Interval: time.Hour * 6,
|
|
}
|
|
}
|
|
|
|
o.Config.Logger = new(void)
|
|
|
|
if o.MemoryInterval < time.Minute {
|
|
o.MemoryInterval = time.Hour * 12
|
|
}
|
|
|
|
segment, err := analytics.NewWithConfig(o.WriteKey, *o.Config)
|
|
if err != nil {
|
|
l.WithError(err).Fatalf("Unable to initialise software quality assurance features.")
|
|
return nil
|
|
}
|
|
|
|
optOut, err := cmd.Flags().GetBool("sqa-opt-out")
|
|
if err != nil {
|
|
cmdx.Must(err, `Unable to get command line flag "sqa-opt-out": %s`, err)
|
|
}
|
|
|
|
if !optOut {
|
|
optOut = c.Bool("sqa.opt_out")
|
|
}
|
|
|
|
if !optOut {
|
|
optOut = c.Bool("sqa_opt_out")
|
|
}
|
|
|
|
if !optOut {
|
|
optOut, _ = strconv.ParseBool(os.Getenv("SQA_OPT_OUT"))
|
|
}
|
|
|
|
if !optOut {
|
|
optOut, _ = strconv.ParseBool(os.Getenv("SQA-OPT-OUT"))
|
|
}
|
|
|
|
if !optOut {
|
|
l.Info("Software quality assurance features are enabled. Learn more at: https://www.ory.com/docs/ecosystem/sqa")
|
|
}
|
|
|
|
m := &Service{
|
|
optOut: optOut,
|
|
instanceId: uuid.Must(uuid.NewV4()).String(),
|
|
o: o,
|
|
c: segment,
|
|
l: l,
|
|
mem: new(MemoryStatistics),
|
|
}
|
|
|
|
instance = m
|
|
|
|
go m.Identify()
|
|
go m.Track()
|
|
|
|
return m
|
|
}
|
|
|
|
// Identify enables reporting to segment.
|
|
func (sw *Service) Identify() {
|
|
IdentifySend(sw, true)
|
|
|
|
// User has not opt-out then make identify to be sent every 6 hours
|
|
if !sw.optOut {
|
|
for range time.Tick(time.Hour * 6) {
|
|
IdentifySend(sw, false)
|
|
}
|
|
}
|
|
}
|
|
|
|
func IdentifySend(sw *Service, startup bool) {
|
|
if err := resilience.Retry(sw.l, time.Minute*5, time.Hour*6, func() error {
|
|
return sw.c.Enqueue(analytics.Identify{
|
|
InstanceId: sw.instanceId,
|
|
DeploymentId: sw.o.DeploymentId,
|
|
Project: sw.o.Service,
|
|
|
|
DatabaseDialect: sw.o.DBDialect,
|
|
ProductVersion: sw.o.BuildVersion,
|
|
ProductBuild: sw.o.BuildHash,
|
|
UptimeDeployment: 0,
|
|
UptimeInstance: math.Round(time.Since(sw.o.StartTime).Seconds()),
|
|
IsDevelopment: sw.o.IsDevelopment,
|
|
IsOptOut: sw.optOut,
|
|
Startup: startup,
|
|
})
|
|
}); err != nil {
|
|
sw.l.WithError(err).Debug("Could not commit anonymized environment information")
|
|
}
|
|
}
|
|
|
|
// Track commits memory statistics to segment.
|
|
func (sw *Service) Track() {
|
|
if sw.optOut {
|
|
return
|
|
}
|
|
|
|
for {
|
|
sw.mem.Update()
|
|
if err := sw.c.Enqueue(analytics.Track{
|
|
InstanceId: sw.instanceId,
|
|
DeploymentId: sw.o.DeploymentId,
|
|
Project: sw.o.Service,
|
|
|
|
CPU: runtime.NumCPU(),
|
|
OsName: runtime.GOOS,
|
|
OsArchitecture: runtime.GOARCH,
|
|
Alloc: sw.mem.Alloc,
|
|
TotalAlloc: sw.mem.TotalAlloc,
|
|
Frees: sw.mem.Frees,
|
|
Mallocs: sw.mem.Mallocs,
|
|
Lookups: sw.mem.Lookups,
|
|
Sys: sw.mem.Sys,
|
|
NumGC: sw.mem.NumGC,
|
|
HeapAlloc: sw.mem.HeapAlloc,
|
|
HeapInuse: sw.mem.HeapInuse,
|
|
HeapIdle: sw.mem.HeapIdle,
|
|
HeapObjects: sw.mem.HeapObjects,
|
|
HeapReleased: sw.mem.HeapReleased,
|
|
HeapSys: sw.mem.HeapSys,
|
|
}); err != nil {
|
|
sw.l.WithError(err).Debug("Could not commit anonymized telemetry data")
|
|
}
|
|
time.Sleep(sw.o.MemoryInterval)
|
|
}
|
|
}
|
|
|
|
// ServeHTTP is a middleware for sending meta information to segment.
|
|
func (sw *Service) ServeHTTP(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
|
var start time.Time
|
|
if !sw.optOut {
|
|
start = time.Now()
|
|
}
|
|
|
|
next(rw, r)
|
|
|
|
if sw.optOut {
|
|
return
|
|
}
|
|
|
|
latency := time.Since(start).Milliseconds()
|
|
path := sw.anonymizePath(r.URL.Path)
|
|
host := urlx.ExtractPublicAddress(sw.o.Hostname, r.Header.Get(XForwardedHostHeader), r.Host)
|
|
|
|
// Collecting request info
|
|
stat, _ := httpx.GetResponseMeta(rw)
|
|
|
|
if err := sw.c.Enqueue(analytics.Page{
|
|
InstanceId: sw.instanceId,
|
|
DeploymentId: sw.o.DeploymentId,
|
|
Project: sw.o.Service,
|
|
UrlHost: host,
|
|
UrlPath: path,
|
|
RequestCode: stat,
|
|
RequestLatency: int(latency),
|
|
}); err != nil {
|
|
sw.l.WithError(err).Debug("Could not commit anonymized telemetry data")
|
|
// do nothing...
|
|
}
|
|
}
|
|
|
|
func (sw *Service) UnaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
|
|
var start time.Time
|
|
if !sw.optOut {
|
|
start = time.Now()
|
|
}
|
|
|
|
resp, err := handler(ctx, req)
|
|
|
|
if sw.optOut {
|
|
return resp, err
|
|
}
|
|
|
|
latency := time.Since(start).Milliseconds()
|
|
|
|
hosts := []string{sw.o.Hostname}
|
|
if md, ok := metadata.FromIncomingContext(ctx); ok {
|
|
for _, h := range knownHeaders {
|
|
if v := md.Get(h); len(v) > 0 {
|
|
hosts = append(hosts, v[0])
|
|
}
|
|
}
|
|
}
|
|
host := urlx.ExtractPublicAddress(hosts...)
|
|
|
|
if err := sw.c.Enqueue(analytics.Page{
|
|
InstanceId: sw.instanceId,
|
|
DeploymentId: sw.o.DeploymentId,
|
|
Project: sw.o.Service,
|
|
UrlHost: host,
|
|
UrlPath: info.FullMethod,
|
|
RequestCode: int(status.Code(err)),
|
|
RequestLatency: int(latency),
|
|
}); err != nil {
|
|
sw.l.WithError(err).Debug("Could not commit anonymized telemetry data")
|
|
// do nothing...
|
|
}
|
|
|
|
return resp, err
|
|
}
|
|
|
|
func (sw *Service) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {
|
|
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
|
|
if sw.optOut {
|
|
return next(ctx, req)
|
|
}
|
|
|
|
start := time.Now()
|
|
resp, err := next(ctx, req)
|
|
latency := time.Since(start).Milliseconds()
|
|
|
|
hosts := []string{sw.o.Hostname}
|
|
for _, h := range append([]string{"Host"}, knownHeaders...) {
|
|
if v := req.Header().Get(h); len(v) > 0 {
|
|
hosts = append(hosts, v)
|
|
}
|
|
}
|
|
host := urlx.ExtractPublicAddress(hosts...)
|
|
|
|
if err := sw.c.Enqueue(analytics.Page{
|
|
InstanceId: sw.instanceId,
|
|
DeploymentId: sw.o.DeploymentId,
|
|
Project: sw.o.Service,
|
|
UrlHost: host,
|
|
UrlPath: req.Spec().Procedure,
|
|
RequestCode: int(status.Code(err)),
|
|
RequestLatency: int(latency),
|
|
}); err != nil {
|
|
sw.l.WithError(err).Debug("Could not commit anonymized telemetry data")
|
|
// do nothing...
|
|
}
|
|
|
|
return resp, err
|
|
}
|
|
}
|
|
|
|
func (sw *Service) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc {
|
|
return next
|
|
}
|
|
func (sw *Service) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc {
|
|
return next
|
|
}
|
|
|
|
func (sw *Service) StreamInterceptor(srv interface{}, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
|
|
// this needs a bit of thought, but we don't have streaming RPCs currently anyway
|
|
sw.l.Info("The telemetry stream interceptor is not yet implemented!")
|
|
return handler(srv, stream)
|
|
}
|
|
|
|
func (sw *Service) Close() error {
|
|
return sw.c.Close()
|
|
}
|
|
|
|
func (sw *Service) anonymizePath(path string) string {
|
|
paths := sw.o.WhitelistedPaths
|
|
path = strings.ToLower(path)
|
|
|
|
for _, p := range paths {
|
|
p = strings.ToLower(p)
|
|
if path == p {
|
|
return p
|
|
} else if len(path) > len(p) && path[:len(p)+1] == p+"/" {
|
|
return p
|
|
}
|
|
}
|
|
|
|
return "/"
|
|
}
|
|
|
|
func (sw *Service) anonymizeQuery(query url.Values, salt string) string {
|
|
for _, q := range query {
|
|
for i, s := range q {
|
|
if s != "" {
|
|
s = Hash(s + "|" + salt)
|
|
q[i] = s
|
|
}
|
|
}
|
|
}
|
|
return query.Encode()
|
|
}
|