fxprofiler 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fxprofiler/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ from fxprofiler.category import Category, CategoryColor
2
+ from fxprofiler.marker import Marker, MarkerTiming
3
+ from fxprofiler.marker_schema import (
4
+ MarkerFormat,
5
+ MarkerGraph,
6
+ MarkerGraphType,
7
+ MarkerLocation,
8
+ PIICategory,
9
+ marker_field,
10
+ )
11
+ from fxprofiler.profile import Profile
12
+ from fxprofiler.thread import Thread
13
+ from fxprofiler.viewer import ProfileServer, show
14
+
15
+ __all__ = [
16
+ "Category",
17
+ "CategoryColor",
18
+ "Marker",
19
+ "MarkerTiming",
20
+ "MarkerFormat",
21
+ "MarkerGraph",
22
+ "MarkerGraphType",
23
+ "MarkerLocation",
24
+ "PIICategory",
25
+ "marker_field",
26
+ "Profile",
27
+ "Thread",
28
+ "ProfileServer",
29
+ "show",
30
+ ]
fxprofiler/category.py ADDED
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+
6
+
7
+ class CategoryColor(str, Enum):
8
+ TRANSPARENT = "transparent"
9
+ LIGHTBLUE = "lightblue"
10
+ RED = "red"
11
+ ORANGE = "orange"
12
+ BLUE = "blue"
13
+ GREEN = "green"
14
+ PURPLE = "purple"
15
+ YELLOW = "yellow"
16
+ MAGENTA = "magenta"
17
+ GREY = "grey"
18
+ DARKGREY = "darkgrey"
19
+ INK = "ink"
20
+ TEAL = "teal"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Category:
25
+ name: str
26
+ color: CategoryColor
27
+ subcategories: tuple[str, ...] = ("Other",)
28
+
29
+ def __post_init__(self) -> None:
30
+ object.__setattr__(self, "subcategories", tuple(self.subcategories))
31
+
32
+
33
+ DEFAULT_CATEGORIES: list[Category] = [
34
+ Category("Other", CategoryColor.GREY),
35
+ Category("Idle", CategoryColor.TRANSPARENT),
36
+ Category("Layout", CategoryColor.PURPLE),
37
+ Category("JavaScript", CategoryColor.YELLOW),
38
+ Category("GC / CC", CategoryColor.ORANGE),
39
+ Category("Network", CategoryColor.LIGHTBLUE),
40
+ Category("Graphics", CategoryColor.GREEN),
41
+ Category("DOM", CategoryColor.BLUE),
42
+ ]
43
+
44
+
45
+ class CategoryRegistry:
46
+ def __init__(self) -> None:
47
+ self._categories: list[Category] = []
48
+ self._index_by_key: dict[tuple[str, CategoryColor], int] = {}
49
+
50
+ def index_for_category(self, category: Category) -> int:
51
+ key = (category.name, category.color)
52
+ index = self._index_by_key.get(key)
53
+ if index is not None:
54
+ return index
55
+ index = len(self._categories)
56
+ self._categories.append(category)
57
+ self._index_by_key[key] = index
58
+ return index
59
+
60
+ def to_list(self) -> list[dict]:
61
+ return [
62
+ {
63
+ "name": category.name,
64
+ "color": category.color.value,
65
+ "subcategories": list(category.subcategories),
66
+ }
67
+ for category in self._categories
68
+ ]
fxprofiler/marker.py ADDED
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ from dataclasses import dataclass
5
+ from enum import IntEnum
6
+ from typing import ClassVar
7
+
8
+ from fxprofiler.category import Category
9
+ from fxprofiler.marker_schema import _FIELD_METADATA_KEY, MarkerGraph, MarkerLocation
10
+
11
+
12
+ class MarkerPhase(IntEnum):
13
+ INSTANT = 0
14
+ INTERVAL = 1
15
+ INTERVAL_START = 2
16
+ INTERVAL_END = 3
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class MarkerTiming:
21
+ phase: MarkerPhase
22
+ start: float | None
23
+ end: float | None
24
+
25
+ @classmethod
26
+ def instant(cls, time: float) -> "MarkerTiming":
27
+ return cls(MarkerPhase.INSTANT, time, None)
28
+
29
+ @classmethod
30
+ def interval(cls, start: float, end: float) -> "MarkerTiming":
31
+ return cls(MarkerPhase.INTERVAL, start, end)
32
+
33
+ @classmethod
34
+ def interval_start(cls, time: float) -> "MarkerTiming":
35
+ return cls(MarkerPhase.INTERVAL_START, time, None)
36
+
37
+ @classmethod
38
+ def interval_end(cls, time: float) -> "MarkerTiming":
39
+ return cls(MarkerPhase.INTERVAL_END, None, time)
40
+
41
+
42
+ @dataclass
43
+ class Marker:
44
+ type_name: ClassVar[str]
45
+ category: ClassVar[Category | None] = None
46
+ locations: ClassVar[tuple[MarkerLocation, ...]] = (
47
+ MarkerLocation.MARKER_CHART,
48
+ MarkerLocation.MARKER_TABLE,
49
+ )
50
+ chart_label: ClassVar[str | None] = None
51
+ tooltip_label: ClassVar[str | None] = None
52
+ table_label: ClassVar[str | None] = None
53
+ description: ClassVar[str | None] = None
54
+ graphs: ClassVar[tuple[MarkerGraph, ...]] = ()
55
+
56
+ def field_values(self) -> dict:
57
+ values = {}
58
+ for field in dataclasses.fields(self):
59
+ if _FIELD_METADATA_KEY in field.metadata:
60
+ values[field.name] = getattr(self, field.name)
61
+ return values
@@ -0,0 +1,180 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+
6
+ from fxprofiler.category import CategoryColor
7
+
8
+ _FIELD_METADATA_KEY = "fxprofiler.marker_field"
9
+
10
+
11
+ class MarkerFormat(str, Enum):
12
+ URL = "url"
13
+ FILE_PATH = "file-path"
14
+ SANITIZED_STRING = "sanitized-string"
15
+ STRING = "string"
16
+ UNIQUE_STRING = "unique-string"
17
+ FLOW_ID = "flow-id"
18
+ TERMINATING_FLOW_ID = "terminating-flow-id"
19
+ DURATION = "duration"
20
+ TIME = "time"
21
+ SECONDS = "seconds"
22
+ MILLISECONDS = "milliseconds"
23
+ MICROSECONDS = "microseconds"
24
+ NANOSECONDS = "nanoseconds"
25
+ BYTES = "bytes"
26
+ PERCENTAGE = "percentage"
27
+ INTEGER = "integer"
28
+ DECIMAL = "decimal"
29
+ HEXADECIMAL = "hexadecimal"
30
+ PID = "pid"
31
+ TID = "tid"
32
+ LIST = "list"
33
+
34
+
35
+ class MarkerLocation(str, Enum):
36
+ MARKER_CHART = "marker-chart"
37
+ MARKER_TABLE = "marker-table"
38
+ TIMELINE_OVERVIEW = "timeline-overview"
39
+ TIMELINE_MEMORY = "timeline-memory"
40
+ TIMELINE_IPC = "timeline-ipc"
41
+ TIMELINE_FILEIO = "timeline-fileio"
42
+ TIMELINE_NETWORK = "timeline-network"
43
+ TIMELINE_SCREENSHOTS = "timeline-screenshots"
44
+
45
+
46
+ class MarkerGraphType(str, Enum):
47
+ BAR = "bar"
48
+ LINE = "line"
49
+ LINE_FILLED = "line-filled"
50
+
51
+
52
+ class PIICategory(str, Enum):
53
+ URL = "url"
54
+ EXTENSION_ID = "extension-id"
55
+ PREFERENCE_VALUE = "preference-value"
56
+ PRIVATE_BROWSING = "private-browsing"
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class MarkerGraph:
61
+ key: str
62
+ type: MarkerGraphType
63
+ color: CategoryColor | None = None
64
+
65
+ def to_dict(self) -> dict:
66
+ result: dict = {"key": self.key, "type": self.type.value}
67
+ if self.color is not None:
68
+ result["color"] = self.color.value
69
+ return result
70
+
71
+
72
+ def marker_field(
73
+ *,
74
+ label: str,
75
+ format: MarkerFormat,
76
+ hidden: bool = False,
77
+ contains_pii: list[PIICategory] | None = None,
78
+ default=None,
79
+ ):
80
+ """Declare a dataclass field on a Marker subclass that is also a schema field."""
81
+ import dataclasses
82
+
83
+ metadata = {
84
+ _FIELD_METADATA_KEY: {
85
+ "label": label,
86
+ "format": format,
87
+ "hidden": hidden,
88
+ "contains_pii": tuple(contains_pii) if contains_pii else (),
89
+ }
90
+ }
91
+ if default is None:
92
+ return dataclasses.field(metadata=metadata)
93
+ return dataclasses.field(default=default, metadata=metadata)
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class MarkerSchemaField:
98
+ key: str
99
+ label: str
100
+ format: MarkerFormat
101
+ hidden: bool = False
102
+ contains_pii: tuple[PIICategory, ...] = ()
103
+
104
+ def to_dict(self) -> dict:
105
+ result: dict = {
106
+ "key": self.key,
107
+ "label": self.label,
108
+ "format": self.format.value,
109
+ }
110
+ if self.hidden:
111
+ result["hidden"] = True
112
+ if self.contains_pii:
113
+ result["containsPII"] = [category.value for category in self.contains_pii]
114
+ return result
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class MarkerSchema:
119
+ type_name: str
120
+ locations: tuple[MarkerLocation, ...]
121
+ fields: tuple[MarkerSchemaField, ...]
122
+ chart_label: str | None = None
123
+ tooltip_label: str | None = None
124
+ table_label: str | None = None
125
+ description: str | None = None
126
+ graphs: tuple[MarkerGraph, ...] = ()
127
+
128
+ def to_dict(self) -> dict:
129
+ result: dict = {
130
+ "name": self.type_name,
131
+ "display": [location.value for location in self.locations],
132
+ "fields": [field.to_dict() for field in self.fields],
133
+ }
134
+ if self.chart_label is not None:
135
+ result["chartLabel"] = self.chart_label
136
+ if self.tooltip_label is not None:
137
+ result["tooltipLabel"] = self.tooltip_label
138
+ if self.table_label is not None:
139
+ result["tableLabel"] = self.table_label
140
+ if self.description is not None:
141
+ result["description"] = self.description
142
+ if self.graphs:
143
+ result["graphs"] = [graph.to_dict() for graph in self.graphs]
144
+ return result
145
+
146
+
147
+ def schema_from_marker_class(marker_class: type) -> MarkerSchema:
148
+ import dataclasses
149
+
150
+ fields: list[MarkerSchemaField] = []
151
+ for dataclass_field in dataclasses.fields(marker_class):
152
+ info = dataclass_field.metadata.get(_FIELD_METADATA_KEY)
153
+ if info is None:
154
+ continue
155
+ fields.append(
156
+ MarkerSchemaField(
157
+ key=dataclass_field.name,
158
+ label=info["label"],
159
+ format=info["format"],
160
+ hidden=info["hidden"],
161
+ contains_pii=info["contains_pii"],
162
+ )
163
+ )
164
+
165
+ return MarkerSchema(
166
+ type_name=marker_class.type_name,
167
+ locations=tuple(
168
+ getattr(
169
+ marker_class,
170
+ "locations",
171
+ (MarkerLocation.MARKER_CHART, MarkerLocation.MARKER_TABLE),
172
+ )
173
+ ),
174
+ fields=tuple(fields),
175
+ chart_label=getattr(marker_class, "chart_label", None),
176
+ tooltip_label=getattr(marker_class, "tooltip_label", None),
177
+ table_label=getattr(marker_class, "table_label", None),
178
+ description=getattr(marker_class, "description", None),
179
+ graphs=tuple(getattr(marker_class, "graphs", ())),
180
+ )
fxprofiler/profile.py ADDED
@@ -0,0 +1,229 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import json
5
+ import os
6
+ import threading
7
+ import time
8
+
9
+ from fxprofiler.category import DEFAULT_CATEGORIES, Category, CategoryRegistry
10
+ from fxprofiler.marker import Marker, MarkerTiming
11
+ from fxprofiler.marker_schema import (
12
+ MarkerFormat,
13
+ MarkerSchema,
14
+ schema_from_marker_class,
15
+ )
16
+ from fxprofiler.strings import StringTable
17
+ from fxprofiler.thread import Thread, _MarkerRow
18
+
19
+ GECKO_PROFILE_VERSION = 24
20
+ PREPROCESSED_PROFILE_VERSION = 75
21
+
22
+ _UNIQUE_STRING_FORMATS = {MarkerFormat.UNIQUE_STRING}
23
+ _FLOW_FORMATS = {MarkerFormat.FLOW_ID, MarkerFormat.TERMINATING_FLOW_ID}
24
+
25
+
26
+ def _empty_table(columns: list[str]) -> dict:
27
+ table = {column: [] for column in columns}
28
+ table["length"] = 0
29
+ return table
30
+
31
+
32
+ class Profile:
33
+ def __init__(
34
+ self, product: str, start_time_ms_since_epoch: float, interval_ms: float = 1.0
35
+ ) -> None:
36
+ self.product = product
37
+ self.start_time = start_time_ms_since_epoch
38
+ self.interval = interval_ms
39
+ self.strings = StringTable()
40
+ self.categories = CategoryRegistry()
41
+ for category in DEFAULT_CATEGORIES:
42
+ self.categories.index_for_category(category)
43
+ self._schemas_by_type_name: dict[str, MarkerSchema] = {}
44
+ self.threads: list[Thread] = []
45
+ self._clock_anchor = time.perf_counter()
46
+
47
+ def now_ms(self) -> float:
48
+ """Milliseconds elapsed since this Profile was constructed, measured with a
49
+ monotonic clock so it is safe to use for marker timing.
50
+ """
51
+ return (time.perf_counter() - self._clock_anchor) * 1000.0
52
+
53
+ def add_thread(
54
+ self,
55
+ name: str,
56
+ pid: int | str | None = None,
57
+ tid: int | str | None = None,
58
+ start_time: float = 0.0,
59
+ end_time: float | None = None,
60
+ is_main: bool = False,
61
+ ) -> Thread:
62
+ """pid/tid default to the current process id and the calling thread's id."""
63
+ thread = Thread(
64
+ name=name,
65
+ pid=str(pid if pid is not None else os.getpid()),
66
+ tid=str(tid if tid is not None else threading.get_ident()),
67
+ start_time=start_time,
68
+ end_time=end_time,
69
+ is_main=is_main,
70
+ )
71
+ self.threads.append(thread)
72
+ return thread
73
+
74
+ @contextlib.contextmanager
75
+ def thread(
76
+ self,
77
+ name: str,
78
+ pid: int | str | None = None,
79
+ tid: int | str | None = None,
80
+ is_main: bool = False,
81
+ ):
82
+ """Adds a thread and records its real start/end time as the block runs.
83
+
84
+ pid/tid default to the current process id and the calling thread's id.
85
+ """
86
+ thread = self.add_thread(
87
+ name, pid, tid, start_time=self.now_ms(), is_main=is_main
88
+ )
89
+ try:
90
+ yield thread
91
+ finally:
92
+ thread.end_time = self.now_ms()
93
+
94
+ def _schema_for(self, marker_class: type) -> MarkerSchema:
95
+ schema = self._schemas_by_type_name.get(marker_class.type_name)
96
+ if schema is None:
97
+ schema = schema_from_marker_class(marker_class)
98
+ self._schemas_by_type_name[marker_class.type_name] = schema
99
+ return schema
100
+
101
+ def _encode_field_value(self, format: MarkerFormat, value):
102
+ if format in _UNIQUE_STRING_FORMATS:
103
+ return self.strings.index_for_string(str(value))
104
+ if format in _FLOW_FORMATS:
105
+ return self.strings.index_for_string(f"{int(value):016x}")
106
+ return value
107
+
108
+ def add_marker(
109
+ self,
110
+ thread: Thread,
111
+ name: str,
112
+ timing: MarkerTiming,
113
+ marker: Marker,
114
+ category: Category | None = None,
115
+ ) -> None:
116
+ schema = self._schema_for(type(marker))
117
+ resolved_category = category or type(marker).category or DEFAULT_CATEGORIES[0]
118
+ category_index = self.categories.index_for_category(resolved_category)
119
+ name_index = self.strings.index_for_string(name)
120
+
121
+ fields_by_key = {field.key: field for field in schema.fields}
122
+ data = {"type": schema.type_name}
123
+ for key, value in marker.field_values().items():
124
+ data[key] = self._encode_field_value(fields_by_key[key].format, value)
125
+
126
+ thread._append_marker_row(
127
+ _MarkerRow(
128
+ name_index=name_index,
129
+ start=timing.start,
130
+ end=timing.end,
131
+ phase=timing.phase,
132
+ category_index=category_index,
133
+ data=data,
134
+ )
135
+ )
136
+
137
+ @contextlib.contextmanager
138
+ def measure(
139
+ self,
140
+ thread: Thread,
141
+ name: str,
142
+ marker: Marker,
143
+ category: Category | None = None,
144
+ ):
145
+ """Records an interval marker for the block's real duration."""
146
+ start = self.now_ms()
147
+ try:
148
+ yield
149
+ finally:
150
+ end = self.now_ms()
151
+ self.add_marker(
152
+ thread, name, MarkerTiming.interval(start, end), marker, category
153
+ )
154
+
155
+ def to_dict(self) -> dict:
156
+ return {
157
+ "meta": {
158
+ "interval": self.interval,
159
+ "startTime": self.start_time,
160
+ "processType": 0,
161
+ "product": self.product,
162
+ "stackwalk": 0,
163
+ "version": GECKO_PROFILE_VERSION,
164
+ "preprocessedProfileVersion": PREPROCESSED_PROFILE_VERSION,
165
+ "symbolicated": True,
166
+ "categories": self.categories.to_list(),
167
+ "markerSchema": [
168
+ schema.to_dict() for schema in self._schemas_by_type_name.values()
169
+ ],
170
+ },
171
+ "libs": [],
172
+ "shared": {
173
+ "stackTable": _empty_table(["frame", "prefixOffset"]),
174
+ "frameTable": _empty_table(
175
+ [
176
+ "flags",
177
+ "address",
178
+ "lib",
179
+ "category",
180
+ "subcategory",
181
+ "func",
182
+ "nativeSymbol",
183
+ "innerWindowID",
184
+ "line",
185
+ "column",
186
+ "originalLocation",
187
+ ]
188
+ ),
189
+ "funcTable": _empty_table(
190
+ [
191
+ "flags",
192
+ "name",
193
+ "resource",
194
+ "source",
195
+ "lineNumber",
196
+ "columnNumber",
197
+ "originalLocation",
198
+ ]
199
+ ),
200
+ "resourceTable": _empty_table(["name", "host", "type"]),
201
+ "nativeSymbols": _empty_table(
202
+ ["libIndex", "address", "name", "functionSize"]
203
+ ),
204
+ "stringArray": self.strings.to_list(),
205
+ "sources": _empty_table(
206
+ [
207
+ "id",
208
+ "filename",
209
+ "startLine",
210
+ "startColumn",
211
+ "sourceMapURL",
212
+ "content",
213
+ "generatedSource",
214
+ ]
215
+ ),
216
+ "sourceLocationTable": _empty_table(["source", "line", "column"]),
217
+ },
218
+ "threads": [thread.to_dict() for thread in self.threads],
219
+ "pages": [],
220
+ "profilerOverhead": [],
221
+ "counters": [],
222
+ }
223
+
224
+ def to_json(self) -> str:
225
+ return json.dumps(self.to_dict())
226
+
227
+ def write_json(self, path: str) -> None:
228
+ with open(path, "w") as f:
229
+ f.write(self.to_json())
fxprofiler/strings.py ADDED
@@ -0,0 +1,16 @@
1
+ class StringTable:
2
+ def __init__(self) -> None:
3
+ self._strings: list[str] = []
4
+ self._index_by_string: dict[str, int] = {}
5
+
6
+ def index_for_string(self, value: str) -> int:
7
+ index = self._index_by_string.get(value)
8
+ if index is not None:
9
+ return index
10
+ index = len(self._strings)
11
+ self._strings.append(value)
12
+ self._index_by_string[value] = index
13
+ return index
14
+
15
+ def to_list(self) -> list[str]:
16
+ return list(self._strings)
fxprofiler/thread.py ADDED
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from fxprofiler.marker import MarkerPhase
6
+
7
+
8
+ @dataclass
9
+ class _MarkerRow:
10
+ name_index: int
11
+ start: float | None
12
+ end: float | None
13
+ phase: MarkerPhase
14
+ category_index: int
15
+ data: dict
16
+
17
+
18
+ class Thread:
19
+ def __init__(
20
+ self,
21
+ name: str,
22
+ pid: str,
23
+ tid: str,
24
+ start_time: float = 0.0,
25
+ end_time: float | None = None,
26
+ is_main: bool = False,
27
+ ) -> None:
28
+ self.name = name
29
+ self.pid = pid
30
+ self.tid = tid
31
+ self.start_time = start_time
32
+ self.end_time = end_time
33
+ self.is_main = is_main
34
+ self._marker_rows: list[_MarkerRow] = []
35
+
36
+ def _append_marker_row(self, row: _MarkerRow) -> None:
37
+ self._marker_rows.append(row)
38
+
39
+ def _markers_to_dict(self) -> dict:
40
+ markers = {
41
+ "data": [],
42
+ "name": [],
43
+ "startTime": [],
44
+ "endTime": [],
45
+ "phase": [],
46
+ "category": [],
47
+ "length": len(self._marker_rows),
48
+ }
49
+ for row in self._marker_rows:
50
+ markers["data"].append(row.data)
51
+ markers["name"].append(row.name_index)
52
+ markers["startTime"].append(row.start if row.start is not None else 0.0)
53
+ markers["endTime"].append(row.end if row.end is not None else 0.0)
54
+ markers["phase"].append(int(row.phase))
55
+ markers["category"].append(row.category_index)
56
+ return markers
57
+
58
+ def to_dict(self) -> dict:
59
+ return {
60
+ "processType": "default",
61
+ "processStartupTime": 0,
62
+ "processShutdownTime": self.end_time,
63
+ "registerTime": self.start_time,
64
+ "unregisterTime": self.end_time,
65
+ "pausedRanges": [],
66
+ "name": self.name,
67
+ "isMainThread": self.is_main,
68
+ "pid": self.pid,
69
+ "tid": self.tid,
70
+ "showMarkersInTimeline": True,
71
+ "samples": {
72
+ "weightType": "samples",
73
+ "weight": None,
74
+ "stack": [],
75
+ "time": [],
76
+ "length": 0,
77
+ },
78
+ "markers": self._markers_to_dict(),
79
+ }
fxprofiler/viewer.py ADDED
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import http.server
4
+ import secrets
5
+ import threading
6
+ import webbrowser
7
+ from urllib.parse import quote
8
+
9
+ from fxprofiler.profile import Profile
10
+
11
+
12
+ def build_profiler_url(
13
+ profile_url: str, profiler_origin: str = "https://profiler.firefox.com"
14
+ ) -> str:
15
+ return f"{profiler_origin}/from-url/{quote(profile_url, safe='')}/"
16
+
17
+
18
+ def _make_handler(
19
+ token: str, payload: bytes
20
+ ) -> type[http.server.BaseHTTPRequestHandler]:
21
+ expected_path = f"/{token}/profile.json"
22
+
23
+ class Handler(http.server.BaseHTTPRequestHandler):
24
+ def do_GET(self) -> None:
25
+ if self.path != expected_path:
26
+ self.send_response(404)
27
+ self.end_headers()
28
+ return
29
+ self.send_response(200)
30
+ self.send_header("Content-Type", "application/json")
31
+ self.send_header("Content-Length", str(len(payload)))
32
+ self.send_header("Access-Control-Allow-Origin", "*")
33
+ self.end_headers()
34
+ self.wfile.write(payload)
35
+
36
+ def log_message(self, format: str, *args) -> None:
37
+ pass
38
+
39
+ return Handler
40
+
41
+
42
+ class ProfileServer:
43
+ def __init__(self, payload: bytes, host: str = "127.0.0.1", port: int = 0) -> None:
44
+ self.token = secrets.token_urlsafe(16)
45
+ handler = _make_handler(self.token, payload)
46
+ self._httpd = http.server.ThreadingHTTPServer((host, port), handler)
47
+ self._thread: threading.Thread | None = None
48
+
49
+ @property
50
+ def port(self) -> int:
51
+ return self._httpd.server_address[1]
52
+
53
+ @property
54
+ def profile_url(self) -> str:
55
+ return f"http://127.0.0.1:{self.port}/{self.token}/profile.json"
56
+
57
+ def start(self) -> None:
58
+ self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True)
59
+ self._thread.start()
60
+
61
+ def shutdown(self) -> None:
62
+ self._httpd.shutdown()
63
+ self._httpd.server_close()
64
+ if self._thread is not None:
65
+ self._thread.join()
66
+
67
+
68
+ def show(
69
+ profile: Profile,
70
+ profiler_origin: str = "https://profiler.firefox.com",
71
+ open_browser=webbrowser.open,
72
+ ) -> ProfileServer:
73
+ server = ProfileServer(profile.to_json().encode("utf-8"))
74
+ server.start()
75
+ open_browser(build_profiler_url(server.profile_url, profiler_origin))
76
+ return server
@@ -0,0 +1,171 @@
1
+ Metadata-Version: 2.4
2
+ Name: fxprofiler
3
+ Version: 0.1.0
4
+ Summary: Create marker-only performance profiles in the Firefox Profiler.
5
+ License-Expression: MPL-2.0
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Dynamic: license-file
10
+
11
+ # fxprofiler
12
+
13
+ Create marker-only performance profiles in the Firefox Profiler. The [profiling](https://docs.python.org/3/library/profiling.html)
14
+ module allows for sampling based profiling, but no markers. Unfortunately it's not possible
15
+ yet to do both sampling and markers in Python together. This package is designed for
16
+ manually instrumenting python code with deterministic tracing markers or spans.
17
+
18
+ ## Define a marker type
19
+
20
+ A simple Text marker can be created to create an Instant marker at a specific point
21
+ in time.
22
+
23
+ ```python
24
+ from dataclasses import dataclass
25
+ from fxprofiler import Marker, MarkerFormat, marker_field
26
+
27
+
28
+ @dataclass
29
+ class TextMarker(Marker):
30
+ type_name = "Text"
31
+ chart_label = "{marker.data.text}"
32
+ table_label = "{marker.name} - {marker.data.text}"
33
+
34
+ text: str = marker_field(label="Contents", format=MarkerFormat.UNIQUE_STRING)
35
+ ```
36
+
37
+ Additional information can be collected through the `marker_field` mechannism.
38
+
39
+ ```python
40
+ from dataclasses import dataclass
41
+ from fxprofiler import (
42
+ Category,
43
+ CategoryColor,
44
+ Marker,
45
+ MarkerFormat,
46
+ MarkerGraph,
47
+ MarkerGraphType,
48
+ marker_field,
49
+ )
50
+
51
+
52
+ @dataclass
53
+ class AllocationMarker(Marker):
54
+ type_name = "custom"
55
+ category = Category("Graphics", CategoryColor.GREEN)
56
+ description = "Tracks a single allocation event."
57
+ tooltip_label = "Allocation - {marker.data.url}"
58
+ graphs = (MarkerGraph(key="latency_ms", type=MarkerGraphType.LINE),)
59
+
60
+ event_name: str = marker_field(
61
+ label="Event name", format=MarkerFormat.UNIQUE_STRING
62
+ )
63
+ allocation_size: int = marker_field(
64
+ label="Allocation size", format=MarkerFormat.BYTES
65
+ )
66
+ url: str = marker_field(label="URL", format=MarkerFormat.URL)
67
+ latency_ms: float = marker_field(label="Latency", format=MarkerFormat.DURATION)
68
+ ```
69
+
70
+ ## Building a profile
71
+
72
+ In order to add markers, a full profile must be built. The profiles follow a thread and
73
+ process model. Even if your system has a different abstraction, you can fit your data
74
+ into these groupings. Threads are grouped by pid in the profiler UI. You can create
75
+ synthetic PIDs to group threads.
76
+
77
+ ```python
78
+ from fxprofiler import Profile, MarkerTiming
79
+
80
+ profile = Profile(product="My Script", start_time_ms_since_epoch=0.0)
81
+ thread = profile.add_thread("Main Thread", pid=1, tid=1, is_main=True)
82
+
83
+ profile.add_marker(
84
+ thread, "startup", MarkerTiming.instant(0.0), TextMarker(text="App launched")
85
+ )
86
+ profile.add_marker(
87
+ thread,
88
+ "allocate",
89
+ MarkerTiming.interval(5.0, 42.0),
90
+ AllocationMarker(
91
+ event_name="alloc",
92
+ allocation_size=512_000,
93
+ url="https://example.com/",
94
+ latency_ms=12.3,
95
+ ),
96
+ )
97
+ ```
98
+
99
+ ## Context managers for timing
100
+
101
+ `measure()` records an interval marker for the duration of the context block.
102
+
103
+ ```python
104
+ with profile.measure(thread, "render", TextMarker(text="Rendered frame")):
105
+ render_frame()
106
+ ```
107
+
108
+ `thread()` adds a thread that is alive for the duration of the context block.
109
+
110
+ ```python
111
+ import threading
112
+
113
+
114
+ def worker(profile):
115
+ with profile.thread("Worker") as thread:
116
+ with profile.measure(thread, "task", TextMarker(text="task")):
117
+ do_work()
118
+
119
+
120
+ threading.Thread(target=worker, args=(profile,)).start()
121
+ ```
122
+
123
+ ## Interval-start / interval-end pairs
124
+
125
+ If markers involve a duration, the API supports adding them with two different markers.
126
+
127
+ ```python
128
+ profile.add_marker(
129
+ thread, "fetch", MarkerTiming.interval_start(2.0), TextMarker(text="fetch started")
130
+ )
131
+ # ... later, possibly after the end time wasn't known yet when the marker started ...
132
+ profile.add_marker(
133
+ thread, "fetch", MarkerTiming.interval_end(30.0), TextMarker(text="fetch finished")
134
+ )
135
+ ```
136
+
137
+ ## Writing and opening a profile
138
+
139
+ When integrating into your app, the profile can be saved and then served directly.
140
+
141
+ ```python
142
+ profile.write_json("profile.json")
143
+ ```
144
+
145
+ ```python
146
+ from fxprofiler import show
147
+
148
+ server = show(profile)
149
+ input("Press Enter to stop serving...\n")
150
+ server.shutdown()
151
+ ```
152
+
153
+ ## Examples
154
+
155
+ Runnable scripts that open profiles live in `examples/`:
156
+
157
+ ```sh
158
+ task example -- markers
159
+ ```
160
+
161
+ ## Development
162
+
163
+ All of the tasks can be run through [Taskfile](https://taskfile.dev/).
164
+
165
+ ## Releasing
166
+
167
+ Describe the release under `## Unreleased` in `CHANGELOG.md`, then run:
168
+
169
+ ```sh
170
+ task publish -- patch # or minor / major
171
+ ```
@@ -0,0 +1,13 @@
1
+ fxprofiler/__init__.py,sha256=dqTR27l_BJG8So8SQlKUYmuccvnTIfjHBgxxTojNJNo,656
2
+ fxprofiler/category.py,sha256=1BcIiuJ0wbulyGDJpSCE7i7hZjdPhJYsRxaFK9aKrzo,1877
3
+ fxprofiler/marker.py,sha256=kWMUtLavixlbCkzDYfpkiY4A4ffhzVIkR9jPh_FsWR8,1738
4
+ fxprofiler/marker_schema.py,sha256=myReDr1wOkVDj9JUCEocdZmjuSKMcX7C7HzzguLw1K4,5211
5
+ fxprofiler/profile.py,sha256=y3IjjT93SNfheqZhBuM8xvyzgpse0Cb-WvK-GbvTP5c,7701
6
+ fxprofiler/strings.py,sha256=FH7ohgaSxg6ArMNEfy_1sjGvUUSFVaYLPpfz2LdCzzg,505
7
+ fxprofiler/thread.py,sha256=pNrAE7IiRjXfAt61mNyHuo5q-2gWESj9QMK0Yp6qPgI,2245
8
+ fxprofiler/viewer.py,sha256=KA8WNYjWB8a1ZhdGkLjWz2yPs4JhigJUuWS3XwVAs40,2317
9
+ fxprofiler-0.1.0.dist-info/licenses/LICENSE,sha256=INpOi7a-Tfo5Tc2Spx5F-qN9TKyy8rB_gn2mEnjcDQ4,15538
10
+ fxprofiler-0.1.0.dist-info/METADATA,sha256=QBHYV8H9x-B48n1lwAgZSa-qrCxaviRevP4Hvz1WLIo,4567
11
+ fxprofiler-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
12
+ fxprofiler-0.1.0.dist-info/top_level.txt,sha256=nWhDDOh5fJht03FJhkEB1mRikwNft8eMbiSKEFwjQ40,11
13
+ fxprofiler-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,364 @@
1
+ Mozilla Public License Version 2.0
2
+ ==================================
3
+
4
+ 1. Definitions
5
+ --------------
6
+
7
+ 1.1. "Contributor"
8
+ means each individual or legal entity that creates, contributes to
9
+ the creation of, or owns Covered Software.
10
+
11
+ 1.2. "Contributor Version"
12
+ means the combination of the Contributions of others (if any) used
13
+ by a Contributor and that particular Contributor's Contribution.
14
+
15
+ 1.3. "Contribution"
16
+ means Covered Software of a particular Contributor.
17
+
18
+ 1.4. "Covered Software"
19
+ means Source Code Form to which the initial Contributor has attached
20
+ the notice in Exhibit A, the Executable Form of such Source Code
21
+ Form, and Modifications of such Source Code Form, in each case
22
+ including portions thereof.
23
+
24
+ 1.5. "Incompatible With Secondary Licenses"
25
+ means
26
+
27
+ (a) that the initial Contributor has attached the notice described
28
+ in Exhibit B to the Covered Software; or
29
+
30
+ (b) that the Covered Software was made available under the terms of
31
+ version 1.1 or earlier of the License, but not also under the
32
+ terms of a Secondary License.
33
+
34
+ 1.6. "Executable Form"
35
+ means any form of the work other than Source Code Form.
36
+
37
+ 1.7. "Larger Work"
38
+ means a work that combines Covered Software with other material, in
39
+ a separate file or files, that is not Covered Software.
40
+
41
+ 1.8. "License"
42
+ means this document.
43
+
44
+ 1.9. "Licensable"
45
+ means having the right to grant, to the maximum extent possible,
46
+ whether at the time of the initial grant or subsequently, any and
47
+ all of the rights conveyed by this License.
48
+
49
+ 1.10. "Modifications"
50
+ means any of the following:
51
+
52
+ (a) any file in Source Code Form that results from an addition to,
53
+ deletion from, or modification of the contents of Covered
54
+ Software; or
55
+
56
+ (b) any new file in Source Code Form that contains any Covered
57
+ Software.
58
+
59
+ 1.11. "Patent Claims" of a Contributor
60
+ means any patent claim(s), including without limitation, method,
61
+ process, and apparatus claims, in any patent Licensable by such
62
+ Contributor that would be infringed, but for the grant of the
63
+ License, by the making, using, selling, offering for sale, having
64
+ made, import, or transfer of either its Contributions or its
65
+ Contributor Version.
66
+
67
+ 1.12. "Secondary License"
68
+ means either the GNU General Public License, Version 2.0, the GNU
69
+ Lesser General Public License, Version 2.1, the GNU Affero General
70
+ Public License, Version 3.0, or any later versions of those
71
+ licenses.
72
+
73
+ 1.13. "Source Code Form"
74
+ means the form of the work preferred for making modifications.
75
+
76
+ 1.14. "You" (or "Your")
77
+ means an individual or a legal entity exercising rights under this
78
+ License. For legal entities, "You" includes any entity that
79
+ controls, is controlled by, or is under common control with You. For
80
+ purposes of this definition, "control" means (a) the power, direct
81
+ or indirect, to cause the direction or management of such entity,
82
+ whether by contract or otherwise, or (b) ownership of more than
83
+ fifty percent (50%) of the outstanding shares or beneficial
84
+ ownership of such entity.
85
+
86
+ 2. License Grants and Conditions
87
+ ---------------------------------
88
+
89
+ 2.1. Grants
90
+
91
+ Each Contributor hereby grants You a world-wide, royalty-free,
92
+ non-exclusive license:
93
+
94
+ (a) under intellectual property rights (other than patent or trademark)
95
+ Licensable by such Contributor to use, reproduce, make available,
96
+ modify, display, perform, distribute, and otherwise exploit its
97
+ Contributions, either on an unmodified basis, with Modifications, or
98
+ as part of a Larger Work; and
99
+
100
+ (b) under Patent Claims of such Contributor to make, use, sell, offer
101
+ for sale, have made, import, and otherwise transfer either its
102
+ Contributions or its Contributor Version.
103
+
104
+ 2.2. Effective Date
105
+
106
+ The licenses granted in Section 2.1 with respect to any Contribution
107
+ become effective for each Contribution on the date the Contributor first
108
+ distributes such Contribution.
109
+
110
+ 2.3. Limitations on Grant Scope
111
+
112
+ The licenses granted in this Section 2 are the only rights granted under
113
+ this License. No additional rights or licenses will be implied from the
114
+ distribution or licensing of Covered Software under this License.
115
+ Notwithstanding Section 2.1(b) above, no patent license is granted by a
116
+ Contributor:
117
+
118
+ (a) for any code that a Contributor has removed from Covered Software;
119
+ or
120
+
121
+ (b) for infringements caused by: (i) Your and any other third party's
122
+ modifications of Covered Software, or (ii) the combination of its
123
+ Contributions with other software (except as part of its Contributor
124
+ Version); or
125
+
126
+ (c) under Patent Claims infringed by Covered Software in the absence of
127
+ its Contributions.
128
+
129
+ This License does not grant any rights in the trademarks, service marks,
130
+ or logos of any Contributor (except as may be necessary to comply with
131
+ the notice requirements in Section 3.4).
132
+
133
+ 2.4. Subsequent Licenses
134
+
135
+ No Contributor makes additional grants as a result of Your choice to
136
+ distribute the Covered Software under a subsequent version of this
137
+ License (see Section 10.2) or under the terms of a Secondary License (if
138
+ permitted under the terms of Section 3.3).
139
+
140
+ 2.5. Representation
141
+
142
+ Each Contributor represents that the Contributor believes its
143
+ Contributions are its original creation(s) or it has sufficient rights
144
+ to grant the rights to its Contributions conveyed by this License.
145
+
146
+ 2.6. Fair Use
147
+
148
+ This License is not intended to limit any rights You have under
149
+ applicable copyright doctrines of fair use, fair dealing, or other
150
+ equivalents.
151
+
152
+ 2.7. Conditions
153
+
154
+ Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
155
+ in Section 2.1.
156
+
157
+ 3. Responsibilities
158
+ --------------------
159
+
160
+ 3.1. Distribution of Source Form
161
+
162
+ All distribution of Covered Software in Source Code Form, including any
163
+ Modifications that You create or to which You contribute, must be under
164
+ the terms of this License. You must inform recipients that the Source
165
+ Code Form of the Covered Software is governed by the terms of this
166
+ License, and how they can obtain a copy of this License. You may not
167
+ attempt to alter or restrict the recipients' rights in the Source Code
168
+ Form.
169
+
170
+ 3.2. Distribution of Executable Form
171
+
172
+ If You distribute Covered Software in Executable Form then:
173
+
174
+ (a) such Covered Software must also be made available in Source Code
175
+ Form, as described in Section 3.1, and You must inform recipients of
176
+ the Executable Form how they can obtain a copy of such Source Code
177
+ Form by reasonable means in a timely manner, at a charge no more
178
+ than the cost of distribution to the recipient; and
179
+
180
+ (b) You may distribute such Executable Form under the terms of this
181
+ License, or sublicense it under different terms, provided that the
182
+ license for the Executable Form does not attempt to limit or alter
183
+ the recipients' rights in the Source Code Form under this License.
184
+
185
+ 3.3. Distribution of a Larger Work
186
+
187
+ You may create and distribute a Larger Work under terms of Your choice,
188
+ provided that You also comply with the requirements of this License for
189
+ the Covered Software. If the Larger Work is a combination of Covered
190
+ Software with a work governed by one or more Secondary Licenses, and the
191
+ Covered Software is not Incompatible With Secondary Licenses, this
192
+ License permits You to additionally distribute such Covered Software
193
+ under the terms of such Secondary License(s), so that the recipient of
194
+ the Larger Work may, at their option, further distribute the Covered
195
+ Software under the terms of either this License or such Secondary
196
+ License(s).
197
+
198
+ 3.4. Notices
199
+
200
+ You may not remove or alter the substance of any license notices
201
+ (including copyright notices, patent notices, disclaimers of warranty,
202
+ or limitations of liability) contained within the Source Code Form of
203
+ the Covered Software, except that You may alter any license notices to
204
+ the extent required to remedy known factual inaccuracies.
205
+
206
+ 3.5. Application of Additional Terms
207
+
208
+ You may choose to offer, and to charge a fee for, warranty, support,
209
+ indemnity or liability obligations to one or more recipients of Covered
210
+ Software. However, You may do so only on Your own behalf, and not on
211
+ behalf of any Contributor. You must make it absolutely clear that any
212
+ such warranty, support, indemnity, or liability obligation is offered by
213
+ You alone, and You hereby agree to indemnify every Contributor for any
214
+ liability incurred by such Contributor as a result of warranty, support,
215
+ indemnity or liability terms You offer. You may include additional
216
+ disclaimers of warranty and limitations of liability specific to any
217
+ jurisdiction.
218
+
219
+ 4. Inability to Comply Due to Statute or Regulation
220
+ -----------------------------------------------------
221
+
222
+ If it is impossible for You to comply with any of the terms of this
223
+ License with respect to some or all of the Covered Software due to
224
+ statute, judicial order, or regulation then You must: (a) comply with
225
+ the terms of this License to the maximum extent possible; and (b)
226
+ describe the limitations and the code they affect. Such description must
227
+ be placed in a text file included with all distributions of the Covered
228
+ Software under this License. Except to the extent prohibited by statute
229
+ or regulation, such description must be sufficiently detailed for a
230
+ recipient of ordinary skill to be able to understand it.
231
+
232
+ 5. Termination
233
+ ---------------
234
+
235
+ 5.1. The rights granted under this License will terminate automatically
236
+ if You fail to comply with any of its terms. However, if You become
237
+ compliant, then the rights granted under this License from a particular
238
+ Contributor are reinstated (a) provisionally, unless and until such
239
+ Contributor explicitly and finally terminates Your grants, and (b) on an
240
+ ongoing basis, if such Contributor fails to notify You of the
241
+ non-compliance by some reasonable means prior to 60 days after You have
242
+ come back into compliance. Moreover, Your grants from a particular
243
+ Contributor are reinstated on an ongoing basis if such Contributor
244
+ notifies You of the non-compliance by some reasonable means, this is the
245
+ first time You have received notice of non-compliance with this License
246
+ from such Contributor, and You become compliant prior to 30 days after
247
+ Your receipt of the notice.
248
+
249
+ 5.2. If You initiate litigation against any entity by asserting a patent
250
+ infringement claim (excluding declaratory judgment actions,
251
+ counter-claims, and cross-claims) alleging that a Contributor Version
252
+ directly or indirectly infringes any patent, then the rights granted to
253
+ You by any and all Contributors for the Covered Software under Section
254
+ 2.1 of this License shall terminate.
255
+
256
+ 5.3. In the event of termination under Sections 5.1 or 5.2 above, all
257
+ end user license agreements (excluding distributors and resellers) which
258
+ have been validly granted by You or Your distributors under this License
259
+ prior to termination shall survive termination.
260
+
261
+ 6. Disclaimer of Warranty
262
+ ---------------------------
263
+
264
+ Covered Software is provided under this License on an "as is" basis,
265
+ without warranty of any kind, either expressed, implied, or statutory,
266
+ including, without limitation, warranties that the Covered Software is
267
+ free of defects, merchantable, fit for a particular purpose or
268
+ non-infringing. The entire risk as to the quality and performance of the
269
+ Covered Software is with You. Should any Covered Software prove
270
+ defective in any respect, You (not any Contributor) assume the cost of
271
+ any necessary servicing, repair, or correction. This disclaimer of
272
+ warranty constitutes an essential part of this License. No use of any
273
+ Covered Software is authorized under this License except under this
274
+ disclaimer.
275
+
276
+ 7. Limitation of Liability
277
+ ----------------------------
278
+
279
+ Under no circumstances and under no legal theory, whether tort
280
+ (including negligence), contract, or otherwise, shall any Contributor,
281
+ or anyone who distributes Covered Software as permitted above, be
282
+ liable to You for any direct, indirect, special, incidental, or
283
+ consequential damages of any character including, without limitation,
284
+ damages for lost profits, loss of goodwill, work stoppage, computer
285
+ failure or malfunction, or any and all other commercial damages or
286
+ losses, even if such party shall have been informed of the possibility
287
+ of such damages. This limitation of liability shall not apply to
288
+ liability for death or personal injury resulting from such party's
289
+ negligence to the extent applicable law prohibits such limitation. Some
290
+ jurisdictions do not allow the exclusion or limitation of incidental or
291
+ consequential damages, so this exclusion and limitation may not apply to
292
+ You.
293
+
294
+ 8. Litigation
295
+ ---------------
296
+
297
+ Any litigation relating to this License may be brought only in the
298
+ courts of a jurisdiction where the defendant maintains its principal
299
+ place of business and such litigation shall be governed by laws of that
300
+ jurisdiction, without reference to its conflict-of-law provisions.
301
+ Nothing in this Section shall prevent a party's ability to bring
302
+ cross-claims or counter-claims.
303
+
304
+ 9. Miscellaneous
305
+ ------------------
306
+
307
+ This License represents the complete agreement concerning the subject
308
+ matter hereof. If any provision of this License is held to be
309
+ unenforceable, such provision shall be reformed only to the extent
310
+ necessary to make it enforceable. Any law or regulation which provides
311
+ that the language of a contract shall be construed against the drafter
312
+ shall not be used to construe this License against a Contributor.
313
+
314
+ 10. Versions of the License
315
+ ------------------------------
316
+
317
+ 10.1. New Versions
318
+
319
+ Mozilla Foundation is the license steward. Except as provided in Section
320
+ 10.3, no one other than the license steward has the right to modify or
321
+ publish new versions of this License. Each version will be given a
322
+ distinguishing version number.
323
+
324
+ 10.2. Effect of New Versions
325
+
326
+ You may distribute the Covered Software under the terms of the version
327
+ of the License under which You originally received the Covered Software,
328
+ or under the terms of any subsequent version published by the license
329
+ steward.
330
+
331
+ 10.3. Modified Versions
332
+
333
+ If you create software not governed by this License, and you want to
334
+ create a new license for such software, you may create and use a
335
+ modified version of this License if you rename the license and remove
336
+ any references to the name of the license steward (except to note that
337
+ such modified license differs from this License).
338
+
339
+ 10.4. Distributing Source Code Form that is Incompatible With Secondary
340
+ Licenses
341
+
342
+ If You choose to distribute Source Code Form that is Incompatible With
343
+ Secondary Licenses under the terms of this version of the License, the
344
+ notice described in Exhibit B of this License must be attached.
345
+
346
+ Exhibit A - Source Code Form License Notice
347
+ ---------------------------------------------
348
+
349
+ This Source Code Form is subject to the terms of the Mozilla Public
350
+ License, v. 2.0. If a copy of the MPL was not distributed with this
351
+ file, You can obtain one at http://mozilla.org/MPL/2.0/.
352
+
353
+ If it is not possible or desirable to put the notice in a particular
354
+ file, then You may include the notice in a location (such as a LICENSE
355
+ file in a relevant directory) where a recipient would be likely to look
356
+ for such a notice.
357
+
358
+ You may add additional accurate notices of copyright ownership.
359
+
360
+ Exhibit B - "Incompatible With Secondary Licenses" Notice
361
+ -------------------------------------------------------------
362
+
363
+ This Source Code Form is "Incompatible With Secondary Licenses", as
364
+ defined by the Mozilla Public License, v. 2.0.
@@ -0,0 +1 @@
1
+ fxprofiler