File: javascript_server.py

package info (click to toggle)
glean-parser 15.0.1-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 1,260 kB
  • sloc: python: 7,033; ruby: 100; makefile: 87
file content (233 lines) | stat: -rw-r--r-- 9,028 bytes parent folder | download | duplicates (14)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
# -*- coding: utf-8 -*-

# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

"""
Outputter to generate server Javascript code for collecting events.

This outputter is different from the rest of the outputters in that the code it
generates does not use the Glean SDK. It is meant to be used to collect events
in server-side environments. In these environments SDK assumptions to measurement
window and connectivity don't hold.
Generated code takes care of assembling pings with metrics, serializing to messages
conforming to Glean schema, and logging with mozlog. Then it's the role of the ingestion
pipeline to pick the messages up and process.

Warning: this outputter supports limited set of metrics,
see `SUPPORTED_METRIC_TYPES` below.

There are two patterns for event structure supported in this environment:
* Events as `Event` metric type, where we generate a single class per ping with
  `record{event_name}` method for each event metric. This is recommended to use for new
  applications as it allows to fully leverage standard Data Platform tools
  post-ingestion.
* Custom pings-as-events, where for each ping we generate a class with a single `record`
  method, usually with an `event_name` string metric.

Therefore, unlike in other outputters, here we don't generate classes for each metric.
"""

from collections import defaultdict
from pathlib import Path
from typing import Any, Dict, Optional, List

from . import __version__
from . import metrics
from . import util

# Adding a metric here will require updating the `generate_js_metric_type` function
# and might require changes to the template.
SUPPORTED_METRIC_TYPES = ["string", "event"]


def event_class_name(
    ping_name: str, metrics_by_type: Dict[str, List[metrics.Metric]]
) -> str:
    # For compatibility with FxA codebase we don't want to add "Logger" suffix
    # when custom pings without event metrics are used.
    event_metric_exists = "event" in metrics_by_type
    suffix = "Logger" if event_metric_exists else ""
    return util.Camelize(ping_name) + "ServerEvent" + suffix


def generate_metric_name(metric: metrics.Metric) -> str:
    return f"{metric.category}.{metric.name}"


def generate_metric_argument_name(metric: metrics.Metric) -> str:
    return f"{metric.category}_{metric.name}"


def generate_js_metric_type(metric: metrics.Metric) -> str:
    return metric.type


def generate_ping_factory_method(
    ping: str, metrics_by_type: Dict[str, List[metrics.Metric]]
) -> str:
    # `ServerEventLogger` better describes role of the class that this factory
    # method generates, but for compatibility with existing FxA codebase
    # we use `Event` suffix if no event metrics are defined.
    event_metric_exists = "event" in metrics_by_type
    suffix = "ServerEventLogger" if event_metric_exists else "Event"
    return f"create{util.Camelize(ping)}{suffix}"


def generate_event_metric_record_function_name(metric: metrics.Metric) -> str:
    return f"record{util.Camelize(metric.category)}{util.Camelize(metric.name)}"


def clean_string(s: str) -> str:
    return s.replace("\n", " ").rstrip()


def output(
    lang: str,
    objs: metrics.ObjectTree,
    output_dir: Path,
    options: Optional[Dict[str, Any]] = None,
) -> None:
    """
    Given a tree of objects, output Javascript or Typescript code to `output_dir`.

    The output is a single file containing all the code for assembling pings with
    metrics, serializing, and submitting.

    :param lang: Either "javascript" or "typescript";
    :param objects: A tree of objects (metrics and pings) as returned from
        `parser.parse_objects`.
    :param output_dir: Path to an output directory to write to.
    """

    if options is None:
        options = {}

    module_spec = options.get("module_spec", "es")
    accepted_module_specs = ["es", "commonjs"]
    if module_spec not in accepted_module_specs:
        raise ValueError(
            f"Unknown module_spec: {module_spec}. Accepted specs are: {accepted_module_specs}."  # noqa
        )

    template = util.get_jinja2_template(
        "javascript_server.jinja2",
        filters=(
            ("event_class_name", event_class_name),
            ("metric_name", generate_metric_name),
            ("metric_argument_name", generate_metric_argument_name),
            ("js_metric_type", generate_js_metric_type),
            ("factory_method", generate_ping_factory_method),
            (
                "event_metric_record_function_name",
                generate_event_metric_record_function_name,
            ),
            ("clean_string", clean_string),
        ),
    )

    event_metric_exists = False

    # Go through all metrics in objs and build a map of
    # ping->list of metric categories->list of metrics
    # for easier processing in the template.
    ping_to_metrics: Dict[str, Dict[str, List[metrics.Metric]]] = defaultdict(dict)
    for _category_key, category_val in objs.items():
        for _metric_name, metric in category_val.items():
            if isinstance(metric, metrics.Metric):
                if metric.type not in SUPPORTED_METRIC_TYPES:
                    print(
                        "❌ Ignoring unsupported metric type: "
                        + f"{metric.type}:{metric.name}."
                        + " Reach out to Glean team to add support for this"
                        + " metric type."
                    )
                    continue
                if metric.type == "event":
                    # This is used in the template - generated code is slightly
                    # different when event metric type is used.
                    event_metric_exists = True
                for ping in metric.send_in_pings:
                    metrics_by_type = ping_to_metrics[ping]
                    metrics_list = metrics_by_type.setdefault(metric.type, [])
                    metrics_list.append(metric)

    # Order pings_to_metrics for backwards compatibility with the existing FxA codebase.
    # Put pings without `event` type metrics first.
    ping_to_metrics = dict(
        sorted(ping_to_metrics.items(), key=lambda item: "event" in item[1])
    )

    PING_METRIC_ERROR_MSG = (
        " Server-side environment is simplified and this"
        + " parser doesn't generate individual metric files. Make sure to pass all"
        + " your ping and metric definitions in a single invocation of the parser."
    )
    if "pings" not in objs:
        # If events are meant to be sent in custom pings, we need to make sure they
        # are defined. Otherwise we won't have destination tables defined and
        # submissions won't pass validation at ingestion.
        if event_metric_exists:
            if "events" not in ping_to_metrics:
                # Event metrics can be sent in standard `events` ping
                # or in custom pings.
                print(
                    "❌ "
                    + PING_METRIC_ERROR_MSG
                    + "\n You need to either send your event metrics in standard"
                    + " `events` ping or define a custom one."
                )
                return
        else:
            print("❌ No ping definition found." + PING_METRIC_ERROR_MSG)
            return

    if not ping_to_metrics:
        print("❌ No pings with metrics found." + PING_METRIC_ERROR_MSG)
        return

    extension = ".js" if lang == "javascript" else ".ts"
    filepath = output_dir / ("server_events" + extension)
    with filepath.open("w", encoding="utf-8") as fd:
        fd.write(
            template.render(
                parser_version=__version__,
                pings=ping_to_metrics,
                event_metric_exists=event_metric_exists,
                module_spec=module_spec,
                lang=lang,
            )
        )


def output_javascript(
    objs: metrics.ObjectTree, output_dir: Path, options: Optional[Dict[str, Any]] = None
) -> None:
    """
    Given a tree of objects, output Javascript code to `output_dir`.

    :param objects: A tree of objects (metrics and pings) as returned from
        `parser.parse_objects`.
    :param output_dir: Path to an output directory to write to.
    :param options: options dictionary, with the following optional keys:

        - `module_spec`: Module specification to use. Options are `es`, `commonjs`.
                        Default is `es`.
    """

    output("javascript", objs, output_dir, options)


def output_typescript(
    objs: metrics.ObjectTree, output_dir: Path, options: Optional[Dict[str, Any]] = None
) -> None:
    """
    Given a tree of objects, output Typescript code to `output_dir`.

    :param objects: A tree of objects (metrics and pings) as returned from
        `parser.parse_objects`.
    :param output_dir: Path to an output directory to write to.
    """

    output("typescript", objs, output_dir, options)