kibana/packages/analytics/client
Spencer afb09ccf8a
Transpile packages on demand, validate all TS projects (#146212)
## Dearest Reviewers 👋 

I've been working on this branch with @mistic and @tylersmalley and
we're really confident in these changes. Additionally, this changes code
in nearly every package in the repo so we don't plan to wait for reviews
to get in before merging this. If you'd like to have a concern
addressed, please feel free to leave a review, but assuming that nobody
raises a blocker in the next 24 hours we plan to merge this EOD pacific
tomorrow, 12/22.

We'll be paying close attention to any issues this causes after merging
and work on getting those fixed ASAP. 🚀

---

The operations team is not confident that we'll have the time to achieve
what we originally set out to accomplish by moving to Bazel with the
time and resources we have available. We have also bought ourselves some
headroom with improvements to babel-register, optimizer caching, and
typescript project structure.

In order to make sure we deliver packages as quickly as possible (many
teams really want them), with a usable and familiar developer
experience, this PR removes Bazel for building packages in favor of
using the same JIT transpilation we use for plugins.

Additionally, packages now use `kbn_references` (again, just copying the
dx from plugins to packages).

Because of the complex relationships between packages/plugins and in
order to prepare ourselves for automatic dependency detection tools we
plan to use in the future, this PR also introduces a "TS Project Linter"
which will validate that every tsconfig.json file meets a few
requirements:

1. the chain of base config files extended by each config includes
`tsconfig.base.json` and not `tsconfig.json`
1. the `include` config is used, and not `files`
2. the `exclude` config includes `target/**/*`
3. the `outDir` compiler option is specified as `target/types`
1. none of these compiler options are specified: `declaration`,
`declarationMap`, `emitDeclarationOnly`, `skipLibCheck`, `target`,
`paths`

4. all references to other packages/plugins use their pkg id, ie:
	
	```js
    // valid
    {
      "kbn_references": ["@kbn/core"]
    }
    // not valid
    {
      "kbn_references": [{ "path": "../../../src/core/tsconfig.json" }]
    }
    ```

5. only packages/plugins which are imported somewhere in the ts code are
listed in `kbn_references`

This linter is not only validating all of the tsconfig.json files, but
it also will fix these config files to deal with just about any
violation that can be produced. Just run `node scripts/ts_project_linter
--fix` locally to apply these fixes, or let CI take care of
automatically fixing things and pushing the changes to your PR.

> **Example:** [`64e93e5`
(#146212)](64e93e5806)
When I merged main into my PR it included a change which removed the
`@kbn/core-injected-metadata-browser` package. After resolving the
conflicts I missed a few tsconfig files which included references to the
now removed package. The TS Project Linter identified that these
references were removed from the code and pushed a change to the PR to
remove them from the tsconfig.json files.

## No bazel? Does that mean no packages??
Nope! We're still doing packages but we're pretty sure now that we won't
be using Bazel to accomplish the 'distributed caching' and 'change-based
tasks' portions of the packages project.

This PR actually makes packages much easier to work with and will be
followed up with the bundling benefits described by the original
packages RFC. Then we'll work on documentation and advocacy for using
packages for any and all new code.

We're pretty confident that implementing distributed caching and
change-based tasks will be necessary in the future, but because of
recent improvements in the repo we think we can live without them for
**at least** a year.

## Wait, there are still BUILD.bazel files in the repo
Yes, there are still three webpack bundles which are built by Bazel: the
`@kbn/ui-shared-deps-npm` DLL, `@kbn/ui-shared-deps-src` externals, and
the `@kbn/monaco` workers. These three webpack bundles are still created
during bootstrap and remotely cached using bazel. The next phase of this
project is to figure out how to get the package bundling features
described in the RFC with the current optimizer, and we expect these
bundles to go away then. Until then any package that is used in those
three bundles still needs to have a BUILD.bazel file so that they can be
referenced by the remaining webpack builds.

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
2022-12-22 19:00:29 -06:00
..
src Upgrade to Jest 29 (#143319) 2022-11-18 09:40:16 -06:00
index.ts chore(NA): remove src folder requirement from packages (part 2) (#138476) 2022-08-30 15:57:35 +01:00
jest.config.js [EBT] Split @elastic/analytics package (#130574) 2022-04-20 15:45:37 +02:00
kibana.jsonc Transpile packages on demand, validate all TS projects (#146212) 2022-12-22 19:00:29 -06:00
package.json Transpile packages on demand, validate all TS projects (#146212) 2022-12-22 19:00:29 -06:00
README.md [EBT] Add flush method and call it during stop (#144925) 2022-11-16 14:48:11 +01:00
tsconfig.json Transpile packages on demand, validate all TS projects (#146212) 2022-12-22 19:00:29 -06:00

@kbn/analytics-client

This module implements the Analytics client used for Event-Based Telemetry. The intention of the client is to be usable on both: the UI and the Server sides.

How to use it

It all starts by creating the client with the createAnalytics API:

import { createAnalytics } from '@kbn/analytics-client';

const analytics = createAnalytics({
  // Set to `true` when running in developer mode.
  // It enables development helpers like schema validation and extra debugging features.
  isDev: false,
  // Set to `staging` if you don't want your events to be sent to the production cluster. Useful for CI & QA environments.
  sendTo: 'production',
  // The application's instrumented logger 
  logger,
});

Reporting events

Reporting events is as simple as calling the reportEvent API every time your application needs to track an event:

analytics.reportEvent('my_unique_event_name', myEventProperties);

But first, it requires a setup phase where the application must declare the event and the structure of the eventProperties:

analytics.registerEventType({
  eventType: 'my_unique_event_name',
  schema: {
    my_keyword: {
      type: 'keyword',
      _meta: {
        description: 'Represents the key property...'
      }
    },
    my_number: {
      type: 'long',
      _meta: {
        description: 'Indicates the number of times...',
        optional: true
      }
    },
    my_complex_unknown_meta_object: {
      type: 'pass_through',
      _meta: {
        description: 'Unknown object that contains the key-values...'
      }
    },
    my_array_of_str: {
      type: 'array',
      items: {
        type: 'text',
        _meta: {
          description: 'List of tags...'
        }
      }
    },
    my_object: {
      properties: {
        my_timestamp: {
          type: 'date',
          _meta: {
            description: 'timestamp when the user...'
          }
        }
      }
    },
    my_array_of_objects: {
      type: 'array',
      items: {
        properties: {
          my_bool_prop: {
            type: 'boolean',
            _meta: {
              description: '`true` when...'
            }
          }
        }
      }
    }
  }
});

For more information about how to declare the schemas, refer to the section Schema definition.

Enriching events

Context is important! For that reason, the client internally appends the timestamp in which the event was generated and any additional context provided by the Context Providers. To register a context provider use the registerContextProvider API:

analytics.registerContextProvider({
  name: 'my_context_provider',
  // RxJS Observable that emits every time the context changes. For example: a License changes from `basic` to `trial`.
  context$,
  // Similar to the `reportEvent` API, schema defining the structure of the expected output of the context$ observable.
  schema,
})

The client cannot send any data until the user provides consent. At the beginning, the client will internally enqueue any incoming events until the consent is either granted or refused.

To set the user's selection use the optIn API:

analytics.optIn({
  global: {
    enabled: true, // The user granted consent
    shippers: {
      shipperA: false, // Shipper A is explicitly disabled for all events
    }
  },
  event_types: {
    my_unique_event_name: {
      enabled: true, // The consent is explictly granted to send this type of event (only if global === true)
      shippers: {
        shipperB: false, // Shipper B is not allowed to report this event.
      }
    },
    my_other_event_name: {
      enabled: false, // The consent is not granted to send this type of event.
    }
  }
})

Explicit flush of the events

If, at any given point (usually testing or during shutdowns) we need to make sure that all the pending events in the queue are sent. The flush API returns a promise that will resolve as soon as all events in the queue are sent.

await analytics.flush()

Shipping events

In order to report the event to an analytics tool, we need to register the shippers our application wants to use. To register a shipper use the API registerShipper:

analytics.registerShipper(ShipperClass, shipperOptions);

There are some prebuilt shippers in this package that can be enabled using the API above. Additionally, each application can register their own custom shippers.

Prebuilt shippers

Refer to the shippers' documentation for more information.

Custom shippers

To use your own shipper, you just need to implement and register it!:

import type { 
  AnalyticsClientInitContext, 
  Event, 
  EventContext, 
  IShipper, 
  TelemetryCounter 
} from '@kbn/analytics-client';

class MyVeryOwnShipper implements IShipper {
  constructor(myOptions: MyOptions, initContext: AnalyticsClientInitContext) {
    // ...
  }

  public reportEvents(events: Event[]): void {
    // Send the events to the analytics platform
  }
  public optIn(isOptedIn: boolean): void {
    // Start/stop any sending mechanisms
  }
  
  public extendContext(newContext: EventContext): void {
    // Call any custom APIs to internally set the context
  }
  
  // Emit any success/failed/dropped activity
  public telemetryCounter$: Observable<TelemetryCounter>;
}

// Register the custom shipper
analytics.registerShipper(MyVeryOwnShipper, myOptions);

Schema definition

Schemas are a framework that allows us to document the structure of the events that our application will report. It is useful to understand the meaning of the events that we report. And, at the same time, it serves as an extra validation step from the developer's point of view.

The syntax of a schema is a simplified ES mapping on steroids: it removes some of the ES mapping complexity, and at the same time, it includes features that are specific to the telemetry collection.

DISCLAIMER: The schema is not a direct mapping to ES indices. The final structure of how the event is stored will depend on many factors like the context providers, shippers and final analytics solution.

Schema Specification: Primitive data types (string, number, boolean)

When declaring primitive values like string or number, the basic schema must contain both: type and _meta.

The type value depends on the type of the content to report in that field. Refer to the table below for the values allowed in the schema type:

Typescript type Schema type
boolean boolean
string keyword
string text
string date (for ISO format)
number date (for ms format)
number byte
number short
number integer
number long
number double
number float
const stringSchema: SchemaValue<string> = {
  type: 'text',
  _meta: {
    description: 'Description of the feature that was broken',
    optional: false,
  },
}

For the _meta, refer to Schema Specification: _meta.

Schema Specification: Objects

Declaring the schema of an object contains 2 main attributes: properties and an optional _meta:

The properties attribute is an object with all the keys that the original object may include:

interface MyObject {
  an_id: string;
  a_description: string;
  a_number?: number;
  a_boolean: boolean;
}

const objectSchema: SchemaObject<MyObject> = {
  properties: {
    an_id: {
      type: 'keyword',
      _meta: {
        description: 'The ID of the element that generated the event',
        optional: false,
      },
    },
    a_description: {
      type: 'text',
      _meta: {
        description: 'The human readable description of the element that generated the event',
        optional: false,
      },
    },
    a_number: {
      type: 'long',
      _meta: {
        description: 'The number of times the element is used',
        optional: true,
      },
    },
    a_boolean: {
      type: 'boolean',
      _meta: {
        description: 'Is the element still active',
        optional: false,
      },
    },
  },
  _meta: {
    description: 'MyObject represents the events generated by elements in the UI when ...',
    optional: false,
  }
}

For the optional _meta, refer to Schema Specification: _meta.

Schema Specification: Arrays

Declaring the schema of an array contains 2 main attributes: items and an optional _meta:

The items attribute is an object declaring the schema of the elements inside the array. At the moment, we only support arrays of one type, so Array<string | number> are not allowed.

type MyArray = string[];

const arraySchema: SchemaArray<MyArray> = {
  items:  {
    type: 'keyword',
    _meta: {
      description: 'Tag attached to the element...',
      optional: false,
    },
  },
  _meta: {
    description: 'List of tags attached to the element...',
    optional: false,
  }
}

For the optional _meta, refer to Schema Specification: _meta.

Schema Specification: Special type pass_through

In case a property in the schema is just used to pass through some unknown content that is declared and validated somewhere else, or that it can dynamically grow and shrink, you may use the type: 'pass_through' option. It behaves like a first-order data type:

type MyUnknownType = unknown;

const passThroughSchema: SchemaValue<MyUnknownType> = {
  type: 'pass_through',
  _meta: {
    description: 'Payload context recevied from the HTTP request...',
    optional: false,
  },
}

For the optional _meta, refer to Schema Specification: _meta.

Schema Specification: _meta

The _meta adds the invaluable information of a description and whether a field is optional in the payload.

It can be attached to any schema definition as seen in the examples above. For high-order types, like arrays or objects, the _meta field is optional. For first-order types, like numbers, strings, booleans or pass_through, the _meta key is mandatory.

The field _meta.optional is not required unless the schema is describing an optional field. In that case, _meta.optional: true is required. However, it's highly encouraged to be explicit about declaring it even when the described field is not optional.

Schema Validation

Apart from documentation, the schema is used to validate the payload during the dev cycle. This adds an extra layer of confidence over the data to be sent.

The validation, however, is disabled in production because users cannot do anything to fix the bug after it is released. Additionally, receiving buggy events can be considered an additional insight into how our users use our products. For example, the buggy event can be caused by a user following an unexpected path in the UI like clicking an "Upload" button when the file has not been selected #125013. In those cases, receiving the incomplete event tells us the user didn't select a file, but they still hit the "Upload" button.

The validation is performed with the io-ts library. In order to do that, the schema is firstly parsed into the io-ts equivalent, and then used to validate the event & context payloads.