Skip to content

Commit

Permalink
Implement opt-in telemetry events
Browse files Browse the repository at this point in the history
  • Loading branch information
radeksimko committed Oct 20, 2021
1 parent 132e82d commit c72acdb
Show file tree
Hide file tree
Showing 20 changed files with 580 additions and 22 deletions.
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,13 @@ Please follow the [relevant guide for your IDE](./docs/USAGE.md).
- [Martin Atkins](https://github.com/apparentlymart) - particularly the virtual filesystem
- [Zhe Cheng](https://github.com/njuCZ) - research, design, prototyping assistance
- [Julio Sueiras](https://github.com/juliosueiras) - particularly his [language server implementation](https://github.com/juliosueiras/terraform-lsp)


## Telemetry

The server may collect data depending on _client-driven_ opt-in or opt-out.
Telemetry is opt-in from server's perspective.

[Read more about telemetry](./docs/telemetry.md).

## `terraform-ls` VS `terraform-lsp`

Expand Down
4 changes: 4 additions & 0 deletions docs/language-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,7 @@ Clients are encouraged to implement custom commands
in a command palette or similar functionality.

See [./commands.md](./commands.md) for more.

## Telemetry

See [./telemetry.md](./telemetry.md).
88 changes: 88 additions & 0 deletions docs/telemetry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Telemetry

The language server is capable of sending telemetry using the native LSP `telemetry/event` method.
Telemetry is off by default and can enabled by passing a supported request format version
as an experimental client capability.

```json
{
"capabilities": {
"experimental": {
"telemetryVersion": 1
}
}
}
```

Clients then implement opt-in or opt-out in the UI and should reflect the user's choice.

## Privacy

Sensitive data, such as filesystem paths or addresses of providers sourced from outside the Terraform Registry
are anonymized. Random UUID is generated in memory and tracked instead of a path or a private provider address.

Mapping of such UUIDs is not persisted anywhere other than in memory during process lifetime.

## Request Format

The only supported version is currently `1`. Version negotiation allows the server
to introduce breaking changes to the format and have clients adopt gradually.

### `v1`

[`telemetry/event`](https://microsoft.github.io/language-server-protocol/specifications/specification-3-16/#telemetry_event) structure

```json
{
"v": 1,
"name": "eventName",
"properties": {}
}
```

`properties` may contain **any (valid) JSON types**
including arrays and arbitrarily nested objects. It is client's
reponsibility to serialize these properties when and if necessary.

Example events:

```json
{
"v": 1,
"name": "initialize",
"properties": {
"experimentalCapabilities.referenceCountCodeLens": true,
"lsVersion": "0.23.0",
"options.commandPrefix": true,
"options.excludeModulePaths": false,
"options.experimentalFeatures.prefillRequiredFields": false,
"options.experimentalFeatures.validateOnSave": false,
"options.rootModulePaths": false,
"options.terraformExecPath": false,
"options.terraformExecTimeout": "",
"options.terraformLogFilePath": false,
"root_uri": "dir"
}
}
```
```json
{
"v": 1,
"name": "moduleData",
"properties": {
"backend": "remote",
"backend.remote.hostname": "app.terraform.io",
"installedProviders": {
"registry.terraform.io/hashicorp/aws": "3.57.0",
"registry.terraform.io/hashicorp/null": "3.1.0"
},
"moduleId": "8aa5a4dc-4780-2d90-b8fb-57de8288fb32",
"providerRequirements": {
"registry.terraform.io/hashicorp/aws": "",
"registry.terraform.io/hashicorp/null": "~> 3.1"
},
"tfRequirements": "~> 1.0",
"tfVersion": "1.0.7"
}
}
```
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@ require (
github.com/hashicorp/go-getter v1.5.9
github.com/hashicorp/go-memdb v1.3.2
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-uuid v1.0.1
github.com/hashicorp/go-version v1.3.0
github.com/hashicorp/hcl-lang v0.0.0-20211014152429-0bfbdcca0902
github.com/hashicorp/hcl/v2 v2.10.1
github.com/hashicorp/terraform-exec v0.15.0
github.com/hashicorp/terraform-json v0.13.0
github.com/hashicorp/terraform-registry-address v0.0.0-20210816115301-cb2034eba045
github.com/hashicorp/terraform-schema v0.0.0-20210823185306-e7a9c4e84cd1
github.com/hashicorp/terraform-schema v0.0.0-20211019141436-db6f95127d12
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mh-cbon/go-fmt-fail v0.0.0-20160815164508-67765b3fbcb5
github.com/mitchellh/cli v1.1.2
Expand Down
6 changes: 2 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -201,14 +201,13 @@ github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2p
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/hashicorp/terraform-exec v0.15.0 h1:cqjh4d8HYNQrDoEmlSGelHmg2DYDh5yayckvJ5bV18E=
github.com/hashicorp/terraform-exec v0.15.0/go.mod h1:H4IG8ZxanU+NW0ZpDRNsvh9f0ul7C0nHP+rUR/CHs7I=
github.com/hashicorp/terraform-json v0.12.0/go.mod h1:pmbq9o4EuL43db5+0ogX10Yofv1nozM+wskr/bGFJpI=
github.com/hashicorp/terraform-json v0.13.0 h1:Li9L+lKD1FO5RVFRM1mMMIBDoUHslOniyEi5CM+FWGY=
github.com/hashicorp/terraform-json v0.13.0/go.mod h1:y5OdLBCT+rxbwnpxZs9kGL7R9ExU76+cpdY8zHwoazk=
github.com/hashicorp/terraform-registry-address v0.0.0-20210412075316-9b2996cce896/go.mod h1:bzBPnUIkI0RxauU8Dqo+2KrZZ28Cf48s8V6IHt3p4co=
github.com/hashicorp/terraform-registry-address v0.0.0-20210816115301-cb2034eba045 h1:R/I8ofvXuPcTNoc//N4ruvaHGZcShI/VuU2iXo875Lo=
github.com/hashicorp/terraform-registry-address v0.0.0-20210816115301-cb2034eba045/go.mod h1:anRyJbe12BZscpFgaeGu9gH12qfdBP094LYFtuAFzd4=
github.com/hashicorp/terraform-schema v0.0.0-20210823185306-e7a9c4e84cd1 h1:K6wkKTi4+aSYXDFGbGWgd3sP+gWTTM9VYOVeCXjJJm8=
github.com/hashicorp/terraform-schema v0.0.0-20210823185306-e7a9c4e84cd1/go.mod h1:wG+IttAk2LqgHE76fD0wt2kucaKzV7pzm7OTqCJJC3M=
github.com/hashicorp/terraform-schema v0.0.0-20211019141436-db6f95127d12 h1:RFY9FoZLxYVbkTHpA4AjOzVMHarnWK9rPxf9oHXyimI=
github.com/hashicorp/terraform-schema v0.0.0-20211019141436-db6f95127d12/go.mod h1:DlxWg9rEgltUs+FD5ElEgBoP985cjAeA9YHcYliAGVg=
github.com/hashicorp/terraform-svchost v0.0.0-20200729002733-f050f53b9734 h1:HKLsbzeOsfXmKNpr3GiT18XAblV0BjCbzL8KQAMZGa0=
github.com/hashicorp/terraform-svchost v0.0.0-20200729002733-f050f53b9734/go.mod h1:kNDNcF7sN4DocDLBkQYz73HGKwN1ANB1blq4lIYLYvg=
github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw=
Expand Down Expand Up @@ -381,7 +380,6 @@ github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zclconf/go-cty v1.1.0/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLEih+O3s=
github.com/zclconf/go-cty v1.2.0/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8=
github.com/zclconf/go-cty v1.2.1/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8=
github.com/zclconf/go-cty v1.8.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk=
github.com/zclconf/go-cty v1.9.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk=
github.com/zclconf/go-cty v1.9.1 h1:viqrgQwFl5UpSxc046qblj78wZXVDFnSOufaOTER+cc=
Expand Down
87 changes: 87 additions & 0 deletions internal/langserver/handlers/hooks_module.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package handlers

import (
"context"
"fmt"

"github.com/hashicorp/terraform-ls/internal/state"
"github.com/hashicorp/terraform-ls/internal/telemetry"
"github.com/hashicorp/terraform-schema/backend"
)

func sendModuleTelemetry(ctx context.Context, store *state.StateStore, telemetrySender telemetry.Sender) state.ModuleChangeHook {
return func(_, newMod *state.Module) {
modId, err := store.GetModuleID(newMod.Path)
if err != nil {
return
}

properties := map[string]interface{}{
"moduleId": modId,
}

if len(newMod.Meta.CoreRequirements) > 0 {
properties["tfRequirements"] = newMod.Meta.CoreRequirements.String()
}
if newMod.Meta.Backend != nil {
properties["backend"] = newMod.Meta.Backend.Type
if data, ok := newMod.Meta.Backend.Data.(*backend.Remote); ok {
hostname := data.Hostname

// anonymize any non-default hostnames
if hostname != "" && hostname != "app.terraform.io" {
hostname = "custom-hostname"
}

properties["backend.remote.hostname"] = hostname
}
}
if len(newMod.Meta.ProviderRequirements) > 0 {
reqs := make(map[string]string, 0)
for pAddr, cons := range newMod.Meta.ProviderRequirements {
if telemetry.IsPublicProvider(pAddr) {
reqs[pAddr.String()] = cons.String()
continue
}

// anonymize any unknown providers or the ones not publicly listed
id, err := store.GetProviderID(pAddr)
if err != nil {
continue
}
addr := fmt.Sprintf("unlisted/%s", id)
reqs[addr] = cons.String()
}
properties["providerRequirements"] = reqs
}

if newMod.TerraformVersion != nil {
properties["tfVersion"] = newMod.TerraformVersion.String()
}

if len(newMod.InstalledProviders) > 0 {
installedProviders := make(map[string]string, 0)
for pAddr, pv := range newMod.InstalledProviders {
if telemetry.IsPublicProvider(pAddr) {
versionString := ""
if pv != nil {
versionString = pv.String()
}
installedProviders[pAddr.String()] = versionString
continue
}

// anonymize any unknown providers or the ones not publicly listed
id, err := store.GetProviderID(pAddr)
if err != nil {
continue
}
addr := fmt.Sprintf("unlisted/%s", id)
installedProviders[addr] = ""
}
properties["installedProviders"] = installedProviders
}

telemetrySender.SendEvent(ctx, "moduleData", properties)
}
}
44 changes: 41 additions & 3 deletions internal/langserver/handlers/initialize.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,41 @@ func (svc *service) Initialize(ctx context.Context, params lsp.InitializeParams)
serverCaps.ServerInfo.Version = version
}

clientCaps := params.Capabilities

properties := map[string]interface{}{
"experimentalCapabilities.referenceCountCodeLens": false,
"options.rootModulePaths": false,
"options.excludeModulePaths": false,
"options.commandPrefix": false,
"options.experimentalFeatures.validateOnSave": false,
"options.terraformExecPath": false,
"options.terraformExecTimeout": "",
"options.terraformLogFilePath": false,
"root_uri": "dir",
"lsVersion": serverCaps.ServerInfo.Version,
}

expClientCaps := lsp.ExperimentalClientCapabilities(clientCaps.Experimental)

if tv, ok := expClientCaps.TelemetryVersion(); ok {
svc.logger.Printf("enabling telemetry (version: %d)", tv)
err := svc.setupTelemetry(tv, jrpc2.ServerFromContext(ctx))
if err != nil {
svc.logger.Printf("failed to setup telemetry: %s", err)
}
svc.logger.Printf("telemetry enabled (version: %d)", tv)
}
defer svc.telemetry.SendEvent(ctx, "initialize", properties)

fh := ilsp.FileHandlerFromDirURI(params.RootURI)
if fh.URI() == "" || !fh.IsDir() {
properties["root_uri"] = "file"
return serverCaps, fmt.Errorf("Editing a single file is not yet supported." +
" Please open a directory.")
}
if !fh.Valid() {
properties["root_uri"] = "invalid"
return serverCaps, fmt.Errorf("URI %q is not valid", params.RootURI)
}

Expand All @@ -74,12 +103,11 @@ func (svc *service) Initialize(ctx context.Context, params lsp.InitializeParams)
}
}

clientCaps := params.Capabilities

if _, ok = lsp.ExperimentalClientCapabilities(clientCaps.Experimental).ShowReferencesCommandId(); ok {
if _, ok = expClientCaps.ShowReferencesCommandId(); ok {
serverCaps.Capabilities.Experimental = lsp.ExperimentalServerCapabilities{
ReferenceCountCodeLens: true,
}
properties["experimentalCapabilities.referenceCountCodeLens"] = true
}

err = lsctx.SetClientCapabilities(ctx, &clientCaps)
Expand All @@ -91,6 +119,16 @@ func (svc *service) Initialize(ctx context.Context, params lsp.InitializeParams)
if err != nil {
return serverCaps, err
}

properties["options.rootModulePaths"] = len(out.Options.ModulePaths) > 0
properties["options.excludeModulePaths"] = len(out.Options.ExcludeModulePaths) > 0
properties["options.commandPrefix"] = len(out.Options.CommandPrefix) > 0
properties["options.experimentalFeatures.prefillRequiredFields"] = out.Options.ExperimentalFeatures.PrefillRequiredFields
properties["options.experimentalFeatures.validateOnSave"] = out.Options.ExperimentalFeatures.ValidateOnSave
properties["options.terraformExecPath"] = len(out.Options.TerraformExecPath) > 0
properties["options.terraformExecTimeout"] = out.Options.TerraformExecTimeout
properties["options.terraformLogFilePath"] = len(out.Options.TerraformLogFilePath) > 0

err = out.Options.Validate()
if err != nil {
return serverCaps, err
Expand Down
17 changes: 17 additions & 0 deletions internal/langserver/handlers/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/hashicorp/terraform-ls/internal/schemas"
"github.com/hashicorp/terraform-ls/internal/settings"
"github.com/hashicorp/terraform-ls/internal/state"
"github.com/hashicorp/terraform-ls/internal/telemetry"
"github.com/hashicorp/terraform-ls/internal/terraform/discovery"
"github.com/hashicorp/terraform-ls/internal/terraform/exec"
"github.com/hashicorp/terraform-ls/internal/terraform/module"
Expand All @@ -48,6 +49,7 @@ type service struct {
tfDiscoFunc discovery.DiscoveryFunc
tfExecFactory exec.ExecutorFactory
tfExecOpts *exec.ExecutorOpts
telemetry telemetry.Sender

additionalHandlers map[string]rpch.Func
}
Expand All @@ -70,6 +72,7 @@ func NewSession(srvCtx context.Context) session.Session {
newWalker: module.NewWalker,
tfDiscoFunc: d.LookPath,
tfExecFactory: exec.NewExecutor,
telemetry: &telemetry.NoopSender{},
}
}

Expand All @@ -89,6 +92,7 @@ func (svc *service) Assigner() (jrpc2.Assigner, error) {
return nil, fmt.Errorf("Unable to prepare session: %w", err)
}

svc.telemetry = &telemetry.NoopSender{Logger: svc.logger}
svc.fs.SetLogger(svc.logger)

lh := LogHandler(svc.logger)
Expand Down Expand Up @@ -443,6 +447,9 @@ func (svc *service) configureSessionDependencies(cfgOpts *settings.Options) erro
return err
}
store.SetLogger(svc.logger)
store.Modules.ChangeHooks = state.ModuleChangeHooks{
sendModuleTelemetry(svc.sessCtx, store, svc.telemetry),
}

err = schemas.PreloadSchemasToStore(store.ProviderSchemas)
if err != nil {
Expand All @@ -469,6 +476,16 @@ func (svc *service) configureSessionDependencies(cfgOpts *settings.Options) erro
return nil
}

func (svc *service) setupTelemetry(version int, notifier session.ClientNotifier) error {
t, err := telemetry.NewSender(version, notifier)
if err != nil {
return err
}

svc.telemetry = t
return nil
}

func (svc *service) Finish(_ jrpc2.Assigner, status jrpc2.ServerStatus) {
if status.Closed || status.Err != nil {
svc.logger.Printf("session stopped unexpectedly (err: %v)", status.Err)
Expand Down
4 changes: 4 additions & 0 deletions internal/langserver/session/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,8 @@ type Session interface {
SetLogger(*log.Logger)
}

type ClientNotifier interface {
Notify(ctx context.Context, method string, params interface{}) error
}

type SessionFactory func(context.Context) Session
9 changes: 9 additions & 0 deletions internal/protocol/experimental.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,12 @@ func (cc ExpClientCapabilities) ShowReferencesCommandId() (string, bool) {
cmdId, ok := cc["showReferencesCommandId"].(string)
return cmdId, ok
}

func (cc ExpClientCapabilities) TelemetryVersion() (int, bool) {
if cc == nil {
return 0, false
}

v, ok := cc["telemetryVersion"].(float64)
return int(v), ok
}
Loading

0 comments on commit c72acdb

Please sign in to comment.