blob: 3e39907ad0140401b35d57095769c0af12096cbb [file]
// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package cloudtail
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"sync"
"time"
"google.golang.org/api/googleapi"
cloudlog "google.golang.org/api/logging/v2"
"go.chromium.org/luci/common/clock"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/common/retry/transient"
"go.chromium.org/luci/common/tsmon/field"
"go.chromium.org/luci/common/tsmon/metric"
)
// DefaultResourceType is used by NewClient if ClientOptions doesn't specify
// ResourceType.
const DefaultResourceType = "machine"
// Entry is a single log entry. It can be a text message, or a JSONish struct.
type Entry struct {
// InsertId can be used to deduplicate log entries.
InsertID string
// Timestamp is an optional timestamp.
Timestamp time.Time
// Severity is the severity of the log entry.
Severity Severity
// TextPayload is the log entry payload, represented as a text string.
TextPayload string
// JSONPayload is the log entry payload, represented as a JSONish structure.
JSONPayload interface{}
// ParsedBy is the parser that parsed this line, or nil if it fell through to
// the default parser.
ParsedBy LogParser
// Labels is a set of user-defined (key, value) data for additional
// information about the log entry.
Labels map[string]string
}
// Client knows how to send entries to Cloud Logging log.
type Client interface {
// PushEntries sends entries to Cloud Logging. No retries.
//
// May return fatal or transient errors. Check with errors.IsTransient.
//
// Respects context deadline.
PushEntries(ctx context.Context, entries []*Entry) error
}
// ClientID uniquely identifies the log entries sent by this process.
//
// Its values are used to identify the log in Cloud Logging and also included in
// monitoring metrics.
type ClientID struct {
// ResourceType identifies a kind of entity that produces this log (e.g.
// 'machine', 'master'). Default is DefaultResourceType.
ResourceType string
// ResourceID identifies exact instance of provided resource type (e.g
// 'vm12-m4', 'master.chromium.fyi'). Default is machine hostname.
ResourceID string
// LogID identifies what sort of log this is. Must be set.
LogID string
}
// ClientOptions is passed to NewClient.
type ClientOptions struct {
// ClientID uniquely identifies the log entries sent by this process.
ClientID
// Client is http.Client to use (that must implement proper authentication).
Client *http.Client
// UserAgent is an optional string appended to User-Agent HTTP header.
UserAgent string
// ProjectID is Cloud project to sends logs to. Must be set.
ProjectID string
// Debug is true to print log entries to stdout instead of sending them.
Debug bool
}
var (
entriesCounter = metric.NewCounter("cloudtail/log_entries",
"Log entries processed",
nil,
field.String("log"),
field.String("resource_type"),
field.String("resource_id"),
field.String("severity"))
writesCounter = metric.NewCounter("cloudtail/api_writes",
"Writes to Cloud Logging API",
nil,
field.String("log"),
field.String("resource_type"),
field.String("resource_id"),
field.String("result"))
)
const (
minBackoffSleep = 5 * time.Second
maxBackoffSleep = 15 * time.Minute
)
// NewClient returns new object that knows how to push log entries to a single
// log in Cloud Logging.
func NewClient(opts ClientOptions) (Client, error) {
if opts.ProjectID == "" {
return nil, fmt.Errorf("no ProjectID is provided")
}
if opts.ResourceType == "" {
opts.ResourceType = DefaultResourceType
}
if opts.ResourceID == "" {
var err error
hostname, err := os.Hostname()
if err != nil {
return nil, err
}
opts.ResourceID = hostname
}
if opts.LogID == "" {
return nil, fmt.Errorf("no LogID is provided")
}
service, err := cloudlog.New(opts.Client)
if err != nil {
return nil, err
}
service.UserAgent = opts.UserAgent
client := &loggingClient{
opts: opts,
service: service,
logName: fmt.Sprintf("projects/%s/logs/%s", opts.ProjectID, url.QueryEscape(opts.LogID)),
}
if opts.ResourceType == DefaultResourceType {
// For "machine" resource types, abuse "gce_instance" resource to have at
// least some level of integration with Log Viewer.
client.resource = &cloudlog.MonitoredResource{
Type: "gce_instance",
Labels: map[string]string{
"project_id": opts.ProjectID,
"instance_id": opts.ResourceID,
},
}
client.labels = map[string]string{
"compute.googleapis.com/resource_name": opts.ResourceID,
"compute.googleapis.com/resource_type": "instance",
// DEPRECATED.
"compute.googleapis.com/resource_id": opts.ResourceID,
}
} else {
// For all other resource types just put stuff in "global" resource, but
// annotate logs with labels.
client.resource = &cloudlog.MonitoredResource{
Type: "global",
Labels: map[string]string{
"project_id": opts.ProjectID,
},
}
client.labels = map[string]string{
"cloudtail/resource_id": opts.ResourceID,
"cloudtail/resource_type": opts.ResourceType,
}
}
if opts.Debug {
client.writeFunc = client.debugWriteFunc
} else {
client.writeFunc = client.cloudLoggingWriteFunc
}
return client, nil
}
////////////////////////////////////////////////////////////////////////////////
type writeFunc func(ctx context.Context, req *cloudlog.WriteLogEntriesRequest) error
type loggingClient struct {
opts ClientOptions
service *cloudlog.Service
// To sleep on HTTP 429.
lock sync.Mutex
backoffSleep time.Duration
// These are passed to Cloud Logging v2 API as is.
logName string
labels map[string]string
resource *cloudlog.MonitoredResource
// writeFunc is mocked in tests.
writeFunc writeFunc
}
func (c *loggingClient) PushEntries(ctx context.Context, entries []*Entry) error {
req := cloudlog.WriteLogEntriesRequest{
Entries: make([]*cloudlog.LogEntry, len(entries)),
LogName: c.logName,
Labels: c.labels,
Resource: c.resource,
}
for i, e := range entries {
entry := &cloudlog.LogEntry{
InsertId: e.InsertID,
Severity: "DEFAULT",
TextPayload: e.TextPayload,
Labels: e.Labels,
}
if e.JSONPayload != nil {
p, err := json.Marshal(e.JSONPayload)
if err != nil {
return err
}
entry.JsonPayload = googleapi.RawMessage(p)
}
if e.Severity != "" {
if err := e.Severity.Validate(); err != nil {
logging.Warningf(ctx, "invalid severity, ignoring: %s", e.Severity)
} else {
entry.Severity = string(e.Severity)
}
}
if !e.Timestamp.IsZero() {
entry.Timestamp = e.Timestamp.UTC().Format(time.RFC3339Nano)
}
req.Entries[i] = entry
entriesCounter.Add(ctx, 1, c.opts.LogID, c.opts.ResourceType, c.opts.ResourceID, entry.Severity)
}
if err := c.writeFunc(ctx, &req); err != nil {
writesCounter.Add(ctx, 1, c.opts.LogID, c.opts.ResourceType, c.opts.ResourceID, "failure")
return err
}
writesCounter.Add(ctx, 1, c.opts.LogID, c.opts.ResourceType, c.opts.ResourceID, "success")
return nil
}
func (c *loggingClient) debugWriteFunc(ctx context.Context, req *cloudlog.WriteLogEntriesRequest) error {
buf, err := json.MarshalIndent(req, "", " ")
if err != nil {
return err
}
fmt.Printf("----------\n%s\n", string(buf))
clock.Sleep(ctx, 30*time.Millisecond)
fmt.Println("----------")
if os.Getenv("CLOUDTAIL_DEBUG_EMULATE_429") != "" {
c.sleepOnRateLimiting(ctx)
return errors.New("emulated HTTP 429", transient.Tag)
}
return nil
}
func (c *loggingClient) cloudLoggingWriteFunc(ctx context.Context, req *cloudlog.WriteLogEntriesRequest) error {
_, err := c.service.Entries.Write(req).Context(ctx).Do()
if err == nil {
c.lock.Lock()
c.backoffSleep = 0
c.lock.Unlock()
return nil
}
if apiErr, _ := err.(*googleapi.Error); apiErr != nil {
if apiErr.Code >= 500 {
return transient.Tag.Apply(err)
}
// HTTP 429 error happens when Cloud Logging is trying to throttle request
// rate. This is global condition, so we keep the sleeping logic in the
// loggingClient itself.
if apiErr.Code == 429 {
c.sleepOnRateLimiting(ctx)
return transient.Tag.Apply(err)
}
return err
}
// The context is dead? Probably fatal error then.
if ctx.Err() != nil {
return err
}
// Non API errors are usually transient, like connection timeout or DNS
// resolution problems.
return transient.Tag.Apply(err)
}
func (c *loggingClient) sleepOnRateLimiting(ctx context.Context) {
c.lock.Lock()
if c.backoffSleep == 0 {
c.backoffSleep = minBackoffSleep
} else {
c.backoffSleep *= 2
if c.backoffSleep > maxBackoffSleep {
c.backoffSleep = maxBackoffSleep
}
}
toSleep := c.backoffSleep
c.lock.Unlock()
logging.Warningf(ctx, "Received HTTP 429, sleeping %s", toSleep)
clock.Sleep(ctx, toSleep)
}