Unverified Commit 2791756c authored by Roberto Sora's avatar Roberto Sora Committed by GitHub

Export Prometheus telemetry in daemon mode (#573)

* Apply cosmetics

* Implement ugly telemetry POC

* Added prefix and moved Insrumentation inside the command package

* Refactor the telemetry module

* Implement configuration via Viper

* Add stats flush in case of a not daemonized cli daemon proces

* Add repertory to store installation id and secret

* Repertory force write

* Cosmetics

* Use viper config for repertory dir

* Add test for repertory file creation

* Add testing for telemetry deaemon and repertory

* Wait for repertory and kill daemon

* Updated pyinvoke to use async feature to test the daemon

* Updated daemon test timeout

* Cosmetics

* Set getDefaultArduinoDataDir as unexported

* Cosmetics

* Cosmetics

* Cosmetics

* Lint on repertory module

* Set SIGTERM as kill signal in case of win platform to overcome pyinvoke bug

* Import platform as a module

* Reverse platform if for signal value

* Extract pid value

* Remove print leftover

* Add better error handling in repertory creation

* Update docs with old README extract

* Remove telemetry.pattern setting from docs

* Remove serverPattern config option for telemetry

* Upgrade viper to 1.6.2

* Defer stats Increment in compile command and explicit set for success/failure

* Fix board list help message

* Implement stats flush anyway to leverage module no-op in case of no handler configured

* Rename "repertory" module in "inventory" and refactor Sanitize function

* Sanitize ExportFile in command/compile

* Refactor daemon start fixture to include daemon process cleanup

* Use defer function to push success tag correctly updated

* Use named return parameters to handle success tagging for a command stat
parent 8483cb2e
......@@ -42,7 +42,7 @@ func initListCommand() *cobra.Command {
}
listCommand.Flags().StringVar(&listFlags.timeout, "timeout", "0s",
"The timeout of the search of connected devices, try to increase it if your board is not found (e.g. to 10s).")
"The connected devices search timeout, raise it if your board doesn't show up (e.g. to 10s).")
return listCommand
}
......
......@@ -39,6 +39,7 @@ import (
"github.com/arduino/arduino-cli/cli/upload"
"github.com/arduino/arduino-cli/cli/version"
"github.com/arduino/arduino-cli/configuration"
"github.com/arduino/arduino-cli/inventory"
"github.com/mattn/go-colorable"
"github.com/rifflock/lfshook"
"github.com/sirupsen/logrus"
......@@ -167,6 +168,9 @@ func preRun(cmd *cobra.Command, args []string) {
configuration.Init(configPath)
configFile := viper.ConfigFileUsed()
// initialize inventory
inventory.Init(viper.GetString("directories.Data"))
//
// Prepare logging
//
......
......@@ -34,6 +34,8 @@ import (
srv_debug "github.com/arduino/arduino-cli/rpc/debug"
srv_monitor "github.com/arduino/arduino-cli/rpc/monitor"
srv_settings "github.com/arduino/arduino-cli/rpc/settings"
"github.com/arduino/arduino-cli/telemetry"
"github.com/segmentio/stats/v4"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/viper"
......@@ -59,16 +61,29 @@ func NewCommand() *cobra.Command {
var daemonize bool
func runDaemonCommand(cmd *cobra.Command, args []string) {
if viper.GetBool("telemetry.enabled") {
telemetry.Activate("daemon")
stats.Incr("daemon", stats.T("success", "true"))
defer stats.Flush()
}
port := viper.GetString("daemon.port")
s := grpc.NewServer()
// register the commands service
headers := http.Header{"User-Agent": []string{
fmt.Sprintf("%s/%s daemon (%s; %s; %s) Commit:%s",
globals.VersionInfo.Application,
globals.VersionInfo.VersionString,
runtime.GOARCH, runtime.GOOS,
runtime.Version(), globals.VersionInfo.Commit)}}
// Compose user agent header
headers := http.Header{
"User-Agent": []string{
fmt.Sprintf("%s/%s daemon (%s; %s; %s) Commit:%s",
globals.VersionInfo.Application,
globals.VersionInfo.VersionString,
runtime.GOARCH,
runtime.GOOS,
runtime.Version(),
globals.VersionInfo.Commit),
},
}
// Register the commands service
srv_commands.RegisterArduinoCoreServer(s, &daemon.ArduinoCoreServerImpl{
DownloaderHeaders: headers,
VersionString: globals.VersionInfo.VersionString,
......@@ -88,6 +103,8 @@ func runDaemonCommand(cmd *cobra.Command, args []string) {
go func() {
// Stdin is closed when the controlling parent process ends
_, _ = io.Copy(ioutil.Discard, os.Stdin)
// Flush telemetry stats (this is a no-op if telemetry is disabled)
stats.Flush()
os.Exit(0)
}()
}
......
......@@ -28,13 +28,12 @@ import (
"github.com/arduino/arduino-cli/arduino/sketches"
"github.com/arduino/arduino-cli/commands"
rpc "github.com/arduino/arduino-cli/rpc/commands"
discovery "github.com/arduino/board-discovery"
paths "github.com/arduino/go-paths-helper"
"github.com/arduino/board-discovery"
"github.com/arduino/go-paths-helper"
)
// Attach FIXMEDOC
func Attach(ctx context.Context, req *rpc.BoardAttachReq, taskCB commands.TaskProgressCB) (*rpc.BoardAttachResp, error) {
pm := commands.GetPackageManager(req.GetInstance().GetId())
if pm == nil {
return nil, errors.New("invalid instance")
......
......@@ -27,6 +27,7 @@ import (
"github.com/arduino/arduino-cli/commands"
rpc "github.com/arduino/arduino-cli/rpc/commands"
"github.com/pkg/errors"
"github.com/segmentio/stats/v4"
"github.com/sirupsen/logrus"
)
......@@ -100,10 +101,21 @@ func identifyViaCloudAPI(port *commands.BoardPort) ([]*rpc.BoardListItem, error)
}
// List FIXMEDOC
func List(instanceID int32) ([]*rpc.DetectedPort, error) {
func List(instanceID int32) (r []*rpc.DetectedPort, e error) {
m.Lock()
defer m.Unlock()
tags := map[string]string{}
// Use defer func() to evaluate tags map when function returns
// and set success flag inspecting the error named return parameter
defer func() {
tags["success"] = "true"
if e != nil {
tags["success"] = "false"
}
stats.Incr("compile", stats.M(tags)...)
}()
pm := commands.GetPackageManager(instanceID)
if pm == nil {
return nil, errors.New("invalid instance")
......
......@@ -22,6 +22,7 @@ import (
"io"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/arduino/arduino-cli/arduino/cores"
......@@ -33,14 +34,42 @@ import (
"github.com/arduino/arduino-cli/legacy/builder/i18n"
"github.com/arduino/arduino-cli/legacy/builder/types"
rpc "github.com/arduino/arduino-cli/rpc/commands"
"github.com/arduino/arduino-cli/telemetry"
paths "github.com/arduino/go-paths-helper"
properties "github.com/arduino/go-properties-orderedmap"
"github.com/segmentio/stats/v4"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
)
// Compile FIXMEDOC
func Compile(ctx context.Context, req *rpc.CompileReq, outStream, errStream io.Writer, debug bool) (*rpc.CompileResp, error) {
func Compile(ctx context.Context, req *rpc.CompileReq, outStream, errStream io.Writer, debug bool) (r *rpc.CompileResp, e error) {
tags := map[string]string{
"fqbn": req.Fqbn,
"sketchPath": telemetry.Sanitize(req.SketchPath),
"showProperties": strconv.FormatBool(req.ShowProperties),
"preprocess": strconv.FormatBool(req.Preprocess),
"buildProperties": strings.Join(req.BuildProperties, ","),
"warnings": req.Warnings,
"verbose": strconv.FormatBool(req.Verbose),
"quiet": strconv.FormatBool(req.Quiet),
"vidPid": req.VidPid,
"exportFile": telemetry.Sanitize(req.ExportFile),
"jobs": strconv.FormatInt(int64(req.Jobs), 10),
"libraries": strings.Join(req.Libraries, ","),
}
// Use defer func() to evaluate tags map when function returns
// and set success flag inspecting the error named return parameter
defer func() {
tags["success"] = "true"
if e != nil {
tags["success"] = "false"
}
stats.Incr("compile", stats.M(tags)...)
}()
pm := commands.GetPackageManager(req.GetInstance().GetId())
if pm == nil {
return nil, errors.New("invalid instance")
......@@ -224,6 +253,5 @@ func Compile(ctx context.Context, req *rpc.CompileReq, outStream, errStream io.W
}
logrus.Tracef("Compile %s for %s successful", sketch.Name, fqbnIn)
return &rpc.CompileResp{}, nil
}
......@@ -35,4 +35,8 @@ func setDefaults(dataDir, userDir string) {
// daemon settings
viper.SetDefault("daemon.port", "50051")
//telemetry settings
viper.SetDefault("telemetry.enabled", true)
viper.SetDefault("telemetry.addr", ":9090")
}
......@@ -309,3 +309,40 @@ FTDebouncer@1.3.0 downloaded
Installing FTDebouncer@1.3.0...
Installed FTDebouncer@1.3.0
```
Using the ``daemon`` mode and the gRPC interface
------------------------------------------------
Arduino CLI can be launched as a gRPC server via the `daemon` command.
The [client_example] folder contains a sample client code that shows how to
interact with the gRPC server. Available services and messages are detailed
in the [gRPC reference] pages.
To provide observability for the gRPC server activities besides logs,
the `daemon` mode activates and exposes by default a [Prometheus](https://prometheus.io/)
endpoint (http://localhost:9090/metrics) that can be fetched for
telemetry data like:
```text
# TYPE daemon_compile counter
daemon_compile{buildProperties="",exportFile="",fqbn="arduino:samd:mkr1000",installationID="ed6f1f22-1fbe-4b1f-84be-84d035b6369c",jobs="0",libraries="",preprocess="false",quiet="false",showProperties="false",sketchPath="5ff767c6fa5a91230f5cb4e267c889aa61489ab2c4f70f35f921f934c1462cb6",success="true",verbose="true",vidPid="",warnings=""} 1 1580385724726
# TYPE daemon_board_list counter
daemon_board_list{installationID="ed6f1f22-1fbe-4b1f-84be-84d035b6369c",success="true"} 1 1580385724833
```
The telemetry settings are exposed via the ``telemetry`` section
in the CLI configuration:
```yaml
telemetry:
enabled: true
addr: :9090
```
[client_example]: https://github.com/arduino/arduino-cli/blob/master/client_example
[gRPC reference]: /rpc/commands
[Prometheus]: https://prometheus.io/
......@@ -17,12 +17,12 @@ require (
github.com/fluxio/multierror v0.0.0-20160419044231-9c68d39025e5 // indirect
github.com/fsnotify/fsnotify v1.4.7
github.com/go-errors/errors v1.0.1
github.com/gofrs/uuid v3.2.0+incompatible
github.com/golang/protobuf v1.3.3
github.com/h2non/filetype v1.0.8 // indirect
github.com/juju/errors v0.0.0-20181118221551-089d3ea4e4d5 // indirect
github.com/juju/loggo v0.0.0-20190526231331-6e530bcce5d8 // indirect
github.com/juju/testing v0.0.0-20190429233213-dfc56b8c09fc // indirect
github.com/kr/pretty v0.1.0 // indirect
github.com/mattn/go-colorable v0.1.2
github.com/mattn/go-runewidth v0.0.2 // indirect
github.com/miekg/dns v1.0.5 // indirect
......@@ -31,11 +31,11 @@ require (
github.com/pmylund/sortutil v0.0.0-20120526081524-abeda66eb583
github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5
github.com/schollz/closestmatch v2.1.0+incompatible
github.com/segmentio/stats/v4 v4.5.3
github.com/sirupsen/logrus v1.4.2
github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a // indirect
github.com/spf13/cobra v0.0.5
github.com/spf13/jwalterweatherman v1.0.0
github.com/spf13/viper v1.3.2
github.com/spf13/viper v1.6.2
github.com/stretchr/testify v1.4.0
go.bug.st/cleanup v1.0.0
go.bug.st/downloader v1.1.0
......@@ -49,5 +49,5 @@ require (
google.golang.org/grpc v1.27.0
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce // indirect
gopkg.in/yaml.v2 v2.2.2
gopkg.in/yaml.v2 v2.2.4
)
This diff is collapsed.
// This file is part of arduino-cli.
//
// Copyright 2020 ARDUINO SA (http://www.arduino.cc/)
//
// This software is released under the GNU General Public License version 3,
// which covers the main part of arduino-cli.
// The terms of this license can be found at:
// https://www.gnu.org/licenses/gpl-3.0.en.html
//
// You can be released from the requirements of the above licenses by purchasing
// a commercial license. Buying such a license is mandatory if you want to
// modify or otherwise use the software for commercial activities involving the
// Arduino software without disclosing the source code of your own applications.
// To purchase a commercial license, send an email to license@arduino.cc.
package inventory
import (
"os"
"path/filepath"
"github.com/arduino/arduino-cli/cli/feedback"
"github.com/gofrs/uuid"
"github.com/spf13/viper"
)
// Store is the Read Only config storage
var Store = viper.New()
var (
// Type is the inventory file type
Type = "yaml"
// Name is the inventory file Name with Type as extension
Name = "inventory" + "." + Type
)
// Init configures the Read Only config storage
func Init(configPath string) {
configFilePath := filepath.Join(configPath, Name)
Store.SetConfigName(Name)
Store.SetConfigType(Type)
Store.AddConfigPath(configPath)
// Attempt to read config file
if err := Store.ReadInConfig(); err != nil {
// ConfigFileNotFoundError is acceptable, anything else
// should be reported to the user
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
generateInstallationData()
writeStore(configFilePath)
} else {
feedback.Errorf("Error reading inventory file: %v", err)
}
}
}
func generateInstallationData() {
installationID, err := uuid.NewV4()
if err != nil {
feedback.Errorf("Error generating installation.id: %v", err)
}
Store.Set("installation.id", installationID.String())
installationSecret, err := uuid.NewV4()
if err != nil {
feedback.Errorf("Error generating installation.secret: %v", err)
}
Store.Set("installation.secret", installationSecret.String())
}
func writeStore(configFilePath string) {
configPath := filepath.Dir(configFilePath)
// Create config dir if not present,
// MkdirAll will retrun no error if the path already exists
if err := os.MkdirAll(configPath, os.FileMode(0755)); err != nil {
feedback.Errorf("Error creating inventory dir: %v", err)
}
// Create file if not present
err := Store.WriteConfigAs(configFilePath)
if err != nil {
feedback.Errorf("Error writing inventory file: %v", err)
}
}
// This file is part of arduino-cli.
//
// Copyright 2020 ARDUINO SA (http://www.arduino.cc/)
//
// This software is released under the GNU General Public License version 3,
// which covers the main part of arduino-cli.
// The terms of this license can be found at:
// https://www.gnu.org/licenses/gpl-3.0.en.html
//
// You can be released from the requirements of the above licenses by purchasing
// a commercial license. Buying such a license is mandatory if you want to
// modify or otherwise use the software for commercial activities involving the
// Arduino software without disclosing the source code of your own applications.
// To purchase a commercial license, send an email to license@arduino.cc.
package telemetry
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"github.com/arduino/arduino-cli/inventory"
"github.com/segmentio/stats/v4"
"github.com/segmentio/stats/v4/prometheus"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
"net/http"
)
// serverPattern is the telemetry endpoint resource path for consume metrics
var serverPattern = "/metrics"
// Activate configures and starts the telemetry server exposing a Prometheus resource
func Activate(metricPrefix string) {
// Create a Prometheus default handler
ph := prometheus.DefaultHandler
// Create a new stats engine with an engine that prepends the "daemon" prefix to all metrics
// and includes the installationID as a tag, then replace the default stats engine
stats.DefaultEngine = stats.WithPrefix(metricPrefix, stats.T("installationID",
inventory.Store.GetString("installation.id")))
// Register the handler so it receives metrics from the default engine.
stats.Register(ph)
// Configure using viper settings
serverAddr := viper.GetString("telemetry.addr")
logrus.Infof("Setting up Prometheus telemetry on %s%s", serverAddr, serverPattern)
go func() {
http.Handle(serverPattern, ph)
logrus.Error(http.ListenAndServe(serverAddr, nil))
}()
}
// Sanitize uses config generated UUID (installation.secret) as an HMAC secret to sanitize and anonymize
// a string, maintaining it distinguishable from a different string from the same Installation
func Sanitize(s string) string {
logrus.Infof("inventory.Store.ConfigFileUsed() %s", inventory.Store.ConfigFileUsed())
installationSecret := inventory.Store.GetString("installation.secret")
// Create a new HMAC by defining the hash type and the key (as byte array)
h := hmac.New(sha256.New, []byte(installationSecret))
// Write Data to it
h.Write([]byte(s))
// Get result and encode as hexadecimal string
return hex.EncodeToString(h.Sum(nil))
}
......@@ -13,10 +13,13 @@
# software without disclosing the source code of your own applications. To purchase
# a commercial license, send an email to license@arduino.cc.
import os
import platform
import signal
import pytest
from invoke.context import Context
import simplejson as json
from invoke import Local
from invoke.context import Context
from .common import Board
......@@ -57,7 +60,7 @@ def run_command(pytestconfig, data_dir, downloads_dir, working_dir):
will work in the same temporary folder.
Useful reference:
http://docs.pyinvoke.org/en/1.2/api/runners.html#invoke.runners.Result
http://docs.pyinvoke.org/en/1.4/api/runners.html#invoke.runners.Result
"""
cli_path = os.path.join(str(pytestconfig.rootdir), "..", "arduino-cli")
env = {
......@@ -78,6 +81,42 @@ def run_command(pytestconfig, data_dir, downloads_dir, working_dir):
return _run
@pytest.fixture(scope="function")
def daemon_runner(pytestconfig, data_dir, downloads_dir, working_dir):
"""
Provide an invoke's `Local` object that has started the arduino-cli in daemon mode.
This way is simple to start and kill the daemon when the test is finished
via the kill() function
Useful reference:
http://docs.pyinvoke.org/en/1.4/api/runners.html#invoke.runners.Local
http://docs.pyinvoke.org/en/1.4/api/runners.html
"""
cli_full_line = os.path.join(str(pytestconfig.rootdir), "..", "arduino-cli daemon")
env = {
"ARDUINO_DATA_DIR": data_dir,
"ARDUINO_DOWNLOADS_DIR": downloads_dir,
"ARDUINO_SKETCHBOOK_DIR": data_dir,
}
os.makedirs(os.path.join(data_dir, "packages"))
run_context = Context()
run_context.cd(working_dir)
# Local Class is the implementation of a Runner abstract class
runner = Local(run_context)
runner.run(
cli_full_line, echo=False, hide=True, warn=True, env=env, asynchronous=True
)
# we block here until the test function using this fixture has returned
yield runner
# Kill the runner's process as we finished our test (platform dependent)
os_signal = signal.SIGTERM
if platform.system() != "Windows":
os_signal = signal.SIGKILL
os.kill(runner.process.pid, os_signal)
@pytest.fixture(scope="function")
def detected_boards(run_command):
"""
......
......@@ -2,5 +2,8 @@ pytest==5.3.4
simplejson==3.17.0
semver==2.9.0
pyserial==3.4
# temporary, replaces invoke==1.3.0 in favour of https://github.com/pyinvoke/invoke/pull/661
git+https://github.com/flazzarini/invoke.git
pyyaml==5.3
prometheus-client==0.7.1
requests==2.22.0
pytest-timeout==1.3.4
invoke==1.4.1
......@@ -2,18 +2,28 @@
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile
# pip-compile test/requirements.in
#
attrs==19.1.0 # via pytest
git+https://github.com/flazzarini/invoke.git
certifi==2019.11.28 # via requests
chardet==3.0.4 # via requests
idna==2.8 # via requests
importlib-metadata==1.5.0 # via pluggy, pytest
invoke==1.4.1
more-itertools==7.1.0 # via pytest
packaging==19.0 # via pytest
pluggy==0.13.1 # via pytest
prometheus-client==0.7.1
py==1.8.0 # via pytest
pyparsing==2.4.0 # via packaging
pyserial==3.4
pytest-timeout==1.3.4
pytest==5.3.4
pyyaml==5.3
requests==2.22.0
semver==2.9.0
simplejson==3.17.0
six==1.12.0 # via packaging
urllib3==1.25.8 # via requests
wcwidth==0.1.7 # via pytest
zipp==2.1.0 # via importlib-metadata
......@@ -36,6 +36,5 @@ def test_board_list(run_command):
def test_board_listall(run_command):
assert run_command("core update-index")
result = run_command("board listall")
print(result.stderr, result.stdout)
assert result.ok
assert ["Board", "Name", "FQBN"] == result.stdout.splitlines()[0].strip().split()
# This file is part of arduino-cli.
#
# Copyright 2020 ARDUINO SA (http://www.arduino.cc/)
#
# This software is released under the GNU General Public License version 3,
# which covers the main part of arduino-cli.
# The terms of this license can be found at:
# https://www.gnu.org/licenses/gpl-3.0.en.html
#
# You can be released from the requirements of the above licenses by purchasing
# a commercial license. Buying such a license is mandatory if you want to modify or
# otherwise use the software for commercial activities involving the Arduino
# software without disclosing the source code of your own applications. To purchase
# a commercial license, send an email to license@arduino.cc.
import os
import time
import pytest
import requests
import yaml
from prometheus_client.parser import text_string_to_metric_families
@pytest.mark.timeout(60)
def test_telemetry_prometheus_endpoint(daemon_runner, data_dir):
# Wait for the inventory file to be created and then parse it
# in order to check the generated ids
inventory_file = os.path.join(data_dir, "inventory.yaml")
while not os.path.exists(inventory_file):
time.sleep(1)
with open(inventory_file, 'r') as stream:
inventory = yaml.safe_load(stream)
# Check if :9090/metrics endpoint is alive,
# telemetry is enabled by default in daemon mode
metrics = requests.get("http://localhost:9090/metrics").text
family = next(text_string_to_metric_families(metrics))
sample = family.samples[0]
assert inventory["installation"]["id"] == sample.labels["installationID"]
......@@ -12,10 +12,11 @@
# otherwise use the software for commercial activities involving the Arduino
# software without disclosing the source code of your own applications. To purchase
# a commercial license, send an email to license@arduino.cc.
import os
import json
import os
import semver
import yaml
def test_help(run_command):
......@@ -72,3 +73,19 @@ def test_log_options(run_command, data_dir):
with open(log_file) as f:
for line in f.readlines():
json.loads(line)
def test_inventory_creation(run_command, data_dir):
"""
using `version` as a test command
"""
# no logs
out_lines = run_command("version").stdout.strip().split("\n")
assert len(out_lines) == 1
# parse inventory file
inventory_file = os.path.join(data_dir, "inventory.yaml")
with open(inventory_file, 'r') as stream:
inventory = yaml.safe_load(stream)
assert "installation" in inventory
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment