import { BaseCustomElement } from "brut-js"
/** Sends performance data to an endpoint in a Brut-powered app that is expected to save it as an Open Telemetry span.
* Uses the W3C-recommended headers "traceparent" and "tracestate" to do this.
*
* ### Supported Metrics
*
* Currently, this will attempt to send "navigation", "largest-contentful-paint", and "first-contentful-paint" back to the server.
* Not all browsers support these, so this element will send back as many as it can. It will wait for all supported metrics to be
* received before contacting the server. It will attempt to do this exactly once.
*
* ### Use
*
* To use this element, your page must have a `<meta>` element that contains the value for "traceparent". It is expected that your
* server will include this in server-generatd HTML. The Brut's `Brut::FrontEnd::Components::Traceparent` component will handle this
* for you. The value for "traceparent" is key to connecting the browser metrics to the back-end request that generated the page.
*
* The element also requires a `url` attribute to know where to send the data. By default, Brut is listening in
* `/__brut/instrumentation`. See the example.
*
* ### Durations vs Timestamps
*
* The performance API produces durations since an origin timestamp. Open Telemetry wants timestamps. In theory,
* `Performance.timeOrigin` is provided by the browser as a reference time when the page started doing anything.
* In practice, this value is incorrect on Firefox, so the element records a timestamp when it is created.
*
* When the data is merged back to the server span, the specific timestamps will not exactly match reality, however the durations will
* be accurate. Note that even if `Performance.timeOrigin` was correct, clock drift between client and server would make
* the timestamps inaccurate anyway.
*
* ### Encoding
*
* The spec for the "tracestate" header leaves open how the data is to be encoded. It supports multiple vendors using a key/value
* pair:
*
* tracestate: honeycomb=«encoded data»,newrelic=«encoded data»
*
* This element uses the vendor name "brut". The data is a Base64-encoded JSON blob containing the data.
*
* tracestate: brut=«Base64 encoded JSON»
*
* The values captured and format of the JSON map closely to Open Telemetry's browser instrumentation format.
* Of course, this element is many magnitudes smaller in size than Open Telemetry's, which is why it exists at all
*
* @example
* <!DOCTYPE html>
* <html>
* <head>
* <meta name="traceparent" content="293874293749237439843294">
* <brut-tracing url="/__brut/instrumentation"></brut-tracing>
* <!-- ... -->
* </head>
* <body>
* <!-- ... -->
* </body>
* </html>
*
* @property {string} url - the url where the trace information is to be sent.
*
* @see {@link https://www.w3.org/TR/trace-context/}
* @see external:Performance
*
* @customElement brut-tracing
*/
class Tracing extends BaseCustomElement {
static tagName = "brut-tracing"
static observedAttributes = [
"url",
"show-warnings",
]
#url = null
#sent = {}
#payload = {}
#timeOrigin = null
#supportedTypes = []
#performanceObserver = new PerformanceObserver( (entries) => {
const navigation = entries.getEntriesByType("navigation")[0]
if (navigation && navigation.loadEventEnd != 0 && !this.#payload.navigation) {
this.#payload.navigation = this.#parseNavigation(navigation)
}
const largestContentfulPaint = entries.getEntriesByType("largest-contentful-paint")
if (largestContentfulPaint.length > 0 && !this.#payload["largest-contentful-paint"]) {
this.#payload["largest-contentful-paint"] = this.#parseLargestContentfulPaint(largestContentfulPaint)
}
const paint = entries.getEntriesByName("first-contentful-paint", "paint")[0]
if (paint && !this.#payload.paint) {
this.#payload.paint = this.#parseFirstContentfulPaint(paint)
}
if ( this.#supportedTypes.every( (type) => this.#payload[type] ) ) {
this.#sendSpans()
this.#payload = {}
}
})
constructor() {
super()
this.#timeOrigin = Date.now()
this.#supportedTypes = [
"navigation",
"largest-contentful-paint",
"paint",
].filter( (type) => {
return PerformanceObserver.supportedEntryTypes.includes(type)
})
}
urlChangedCallback({newValue}) {
this.#url = newValue
}
update() {
this.#supportedTypes.forEach( (type) => {
this.#performanceObserver.observe({type: type, buffered: true})
})
}
#sendSpans() {
const headers = this.#initializerHeadersIfCanContinue()
if (!headers) {
return
}
const span = this.#payload.navigation
if (this.#payload.paint) {
span.events.push({
name: this.#payload.paint.name,
timestamp: this.#timeOrigin + this.#payload.paint.startTime
})
}
if (this.#payload["largest-contentful-paint"]) {
this.#payload["largest-contentful-paint"].forEach( (event) => {
span.events.push({
name: event.name,
timestamp: this.#timeOrigin + event.startTime,
attributes: {
"element.tag": event.element?.tagName,
"element.class": event.element?.className,
}
})
})
}
this.#sent[this.#url] = true
headers.append("tracestate",`brut=${window.btoa(JSON.stringify(span))}`)
const request = new Request(
this.#url,
{
headers: headers,
method: "GET",
}
)
fetch(request).then( (response) => {
if (!response.ok) {
console.warn("Problem sending instrumentation: %s/%s", response.status,response.statusText)
}
}).catch( (error) => {
console.warn("Problem sending instrumentation: %o", error)
})
}
#parseNavigation(navigation) {
const documentFetch = {
name: "browser.documentFetch",
start_timestamp: navigation.fetchStart + this.#timeOrigin,
end_timestamp: navigation.responseEnd + this.#timeOrigin,
attributes: {
"http.url": navigation.name,
},
}
const events = [
"fetchStart",
"unloadEventStart",
"unloadEventEnd",
"domInteractive",
"domInteractive",
"domContentLoadedEventStart",
"domContentLoadedEventEnd",
"domComplete",
"loadEventStart",
"loadEventEnd",
]
return {
name: "browser.documentLoad",
start_timestamp: navigation.fetchStart + this.#timeOrigin,
end_timestamp: navigation.loadEventEnd + this.#timeOrigin,
attributes: {
"http.url": navigation.name,
"http.user_agent": window.navigator.userAgent,
},
events: events.map( (eventName) => {
return {
name: eventName,
timestamp: this.#timeOrigin + navigation[eventName],
}
}),
spans: [
documentFetch
]
}
}
#parseFirstContentfulPaint(paint) {
return {
name: "browser.first-contentful-paint",
startTime: paint.startTime,
}
}
#parseLargestContentfulPaint(largestContentfulPaint) {
return largestContentfulPaint.map( (entry) => {
return {
name: "browser.largest-contentful-paint",
startTime: entry.startTime,
element: entry.element,
}
})
}
#initializerHeadersIfCanContinue() {
if (!this.#url) {
this.logger.info("No url set, no traces will be reported")
return
}
const $traceparent = document.querySelector("meta[name='traceparent']")
if (!$traceparent) {
this.logger.info("No <meta name='traceparent' ...> in the document, no traces can be reported")
return
}
if (this.#sent[this.#url]) {
this.logger.info("Already sent to %s", this.#url)
return
}
const traceparent = $traceparent.getAttribute("content")
if (!traceparent) {
this.logger.info("%o had no value for the content attribute, no traces can be reported",$traceparent)
return
}
return new Headers({ traceparent })
}
}
export default Tracing