Skip to content

feat(cmcd): custom events, custom keys and ec#7887

Open
cotid-qualabs wants to merge 19 commits into
video-dev:masterfrom
qualabs:issue/7886-cmcdv2-custom-events-custom-keys-and-ec
Open

feat(cmcd): custom events, custom keys and ec#7887
cotid-qualabs wants to merge 19 commits into
video-dev:masterfrom
qualabs:issue/7886-cmcdv2-custom-events-custom-keys-and-ec

Conversation

@cotid-qualabs
Copy link
Copy Markdown

@cotid-qualabs cotid-qualabs commented Jun 1, 2026

Summary

This PR adds cmcd.reporterCallback to CMCDControllerConfig, enabling runtime custom key injection and programmatic custom event reporting for CMCD v2.

What changed

cmcd.reporterCallback — an optional (reporter: CmcdCustomControllerAPI) => void callback invoked once per MANIFEST_LOADING, before the reporter starts. This ordering ensures any custom data seeded in the callback is included in the first set of time-interval events. A new source load produces a new reporter instance, so the callback fires again on each load.

The callback receives a CmcdCustomControllerAPI facade (not the raw CmcdReporter) that exposes two methods:

  • updateCustomData(data) — sets persistent custom key/value pairs included in every subsequent report. Keys that don't follow the CMCD custom key convention (<reverse-dns>-<label>) are silently dropped via isCmcdCustomKey.
  • recordCustomEvent(eventName, data?) — fires a one-off CMCD custom event (ce) with the given name and optional custom data. Requires cmcd.eventTargets to be configured with a target whose events array includes 'ce'.

Restricting the exposed interface to these two methods prevents consumers from calling internal reporter lifecycle methods (start, stop, flush) that could cause inconsistencies in reporting.

Fatal error events now include { ec: [data.details] } in the event payload, so the hls.js error detail is captured in the CMCD error report.

Key design decisions

  • No new public methods on CMCDController or Hls. The facade is surfaced exclusively via reporterCallback, keeping CMCD optional and off the top-level player API surface.
  • Custom key names must still be declared in the relevant includeKeys array (top-level for request reports, per-target for event reports) so they pass the reporter's key filter.
  • Validation of custom keys uses isCmcdCustomKey from @svta/cml-cmcd — no additional bundle weight from the heavier validateCmcdKeys helper. Advanced users can import it directly for more rigorous validation (see API.md).
  • Key names must follow the reverse-DNS convention (e.g. com.example-myKey). See the SVTA custom keys registry.

Usage

let cmcdReporter = null;

const hls = new Hls({
  cmcd: {
    version: 2,
    contentId: 'my-content',
    includeKeys: ['sid', 'cid', 'sf', 'st', 'bl', 'br', 'mtp', 'com.myco-adBreak'],
    eventTargets: [
      {
        url: 'https://analytics.example.com/cmcd',
        events: ['ce'],
        includeKeys: ['sid', 'cid', 'cen', 'com.myco-chapter'],
      },
    ],
    reporterCallback: (reporter) => {
      cmcdReporter = reporter;
      // Seed persistent custom data — included from the very first report
      reporter.updateCustomData({
        'com.myco-adBreak': 'false',
        'com.myco-chapter': 'intro',
      });
    },
  },
});

// Update custom key state at runtime (takes effect on the next report)
cmcdReporter?.updateCustomData({
  'com.myco-adBreak': adManager.isInAdBreak() ? 'true' : 'false',
});

// Fire a one-off CMCD custom event
cmcdReporter?.recordCustomEvent('chapter-change');

@cotid-qualabs cotid-qualabs marked this pull request as ready for review June 1, 2026 16:27
Copy link
Copy Markdown
Collaborator

@robwalch robwalch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exposing the CMCD controller and allowing any event or arbitrary data to be recorded or updated seems extreme and prone to tampering. I like that in the example in the description, the event and keys are included, but there is no similar example in the docs, nor any enforcement in the implementation.

If you really want unrestricted access, you might as well expose the reporter. Otherwise, if we want only responsible access to set custom fields and record custom events on top of an existing session, then I think a more careful plan or interface is needed. One way that similar customizations work in hls.js is by passing in callbacks to the config.

@cotid-qualabs
Copy link
Copy Markdown
Author

cotid-qualabs commented Jun 1, 2026

One way that similar customizations work in hls.js is by passing in callbacks to the config

Thanks for the review! I looked into the config callback approach (similar to xhrSetup/fetchSetup) and here is what I found:

It's a great pattern for the update() use case: I could add something like cmcd.cmcdSetup(data, { requestType }) that gets called automatically on every request, so the consumer never needs a reference to the controller.

The problem is that recordEvent() is caller-triggered, the consumer decides when to fire it based on external events. hls.js has no internal trigger point for that, so I think a config callback doesn't fit exactly. If there's no natural callback entry point for that in the config, we'd still need to expose something on the Hls instance for it.

Given that, I am thinking about a narrow interface instead, which covers both methods without exposing controller internals:

export interface CmcdControllerAPI {
  update(data: Cmcd): void;
  recordEvent(eventType: CmcdEventType | string, data?: Cmcd): void;
}

hls.cmcdController is now typed as CmcdControllerAPI rather than the concrete CMCDController class.

Let me know what you think

@robwalch
Copy link
Copy Markdown
Collaborator

robwalch commented Jun 2, 2026

One way that similar customizations work in hls.js is by passing in callbacks to the config

Thanks for the review! I looked into the config callback approach (similar to xhrSetup/fetchSetup) and here is what I found:

It's a great pattern for the update() use case: I could add something like cmcd.cmcdSetup(data, { requestType }) that gets called automatically on every request, so the consumer never needs a reference to the controller.

The problem is that recordEvent() is caller-triggered, the consumer decides when to fire it based on external events. hls.js has no internal trigger point for that, so I think a config callback doesn't fit exactly. If there's no natural callback entry point for that in the config, we'd still need to expose something on the Hls instance for it.

Given that, I am thinking about a narrow interface instead, which covers both methods without exposing controller internals:

export interface CmcdControllerAPI {
  update(data: Cmcd): void;
  recordEvent(eventType: CmcdEventType | string, data?: Cmcd): void;
}

hls.cmcdController is now typed as CmcdControllerAPI rather than the concrete CMCDController class.

Let me know what you think

That only narrows the interface to what I already expressed concern for. Exposing the controller in this way would encourage its use not only externally but internally, and we want to keep the cmcd implementation encapsulated in the controller and it's configuration.

I agree that update is easy to solve. You could even have something as simple as a dictionary object with custom key value pairs - no callback needed to spy on requests.

If you want to record custom events, and the like, I guess you do want access to the reporter. Yes, it gives you full access, but at least it's not access to the controller from top level HLS.js. A config option could be set to expose it for custom reporting. This is also how you might provide access with a callback. The callback would be invoked during MANIFEST_LOADING when the reporter is created for a new session:

const hls = new Hls({
  cmcd: {
    version: 2,
    reporterCallback: (reporter: CmcdReporter) => /* now my app can do all the reporting it wants, but only if this option was set */
  },
});

@cotid-qualabs
Copy link
Copy Markdown
Author

Hi @robwalch ! Thanks for your suggestion, here is a Summary of what I implemented:

Custom keys are injected at two levels. At the cmcd config level, customKeys: Partial is merged into every request report (manifests and segments) inside buildRequestData on each call, so mutating hls.config.cmcd.customKeys at runtime takes effect on the next request with no reload. At the eventTargets level, each target also accepts a customKeys field; these are seeded as persistent reporter state via reporter.update() during initialization, so they appear automatically in that target's event reports. In both cases, keys are passed through sanitizeCmcdData() first, which calls validateCmcdKeys() from @svta/cml-cmcd to strip invalid entries and emit warnings.

Reporter exposure is handled via a new reporterCallback: (reporter: CmcdReporter) => void config field. After the CmcdReporter is created and started on each MANIFEST_LOADING, the callback is invoked with the live reporter instance. The consumer stores that reference to call reporter.recordEvent() or reporter.update() directly — enabling custom event reports and runtime state updates without any new public methods on CMCDController itself. Because a new source load produces a new reporter, the callback fires again each time so the consumer always holds the current instance.

Let me know what you think, happy to keep improving the solution

@robwalch robwalch linked an issue Jun 3, 2026 that may be closed by this pull request
Copy link
Copy Markdown
Collaborator

@robwalch robwalch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the customKeys options should be removed completely in favor of developers using the reporter (.update) themselves. This implementation only updates the reporter with those options on start, but then requires them to update themselves anyway. I don't see the value in that.

Comment thread src/controller/cmcd-controller.ts Outdated
],
eventTargets: (cmcd.eventTargets ?? []).map(
({ includeKeys, ...rest }) => ({
({ includeKeys, customKeys: targetCustomKeys, ...rest }) => ({
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this not being used anymore?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in f0cf96f

Comment thread src/controller/cmcd-controller.ts Outdated
Comment on lines +109 to +118
// Seed persistent state for event-target custom keys. Each target's enabledKeys
// controls which of these actually reach that target; they are excluded from
// request reports because they are not in the top-level enabledKeys.
const eventTargetCustomKeys: Cmcd = {};
(cmcd.eventTargets ?? []).forEach((t) => {
if (t.customKeys) Object.assign(eventTargetCustomKeys, t.customKeys);
});
if (Object.keys(eventTargetCustomKeys).length > 0) {
this.reporter.update(this.sanitizeCmcdData(eventTargetCustomKeys));
}
Copy link
Copy Markdown
Collaborator

@robwalch robwalch Jun 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if you want to have the same custom key with different values across targets? I guess there's only one state dictionary per reporter?

This block seems like a good candidate for optimization. It does a few things that it probably should not when there are no custom keys or event targets (call forEach, and sanitize and update).

Ultimately, after reviewing and understanding the implementation, I think both customKeys options should be removed in favor of developers using the reporter directly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in f0cf96f

Comment thread src/controller/cmcd-controller.ts Outdated
Comment on lines +112 to +113
const eventTargetCustomKeys: Cmcd = {};
(cmcd.eventTargets ?? []).forEach((t) => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would prefer an if (CMCD.eventTargets) { block to skip all this rather than the eager evaluation with ?? [].

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 6f9ed8f

Comment thread src/controller/cmcd-controller.ts Outdated
Comment on lines +128 to +134
const invalidKeys = new Set(issues.map(({ key }) => key));
issues.forEach(({ message }) => this.hls.logger.warn(message));
const filtered: Record<string, unknown> = {};
Object.entries(data).forEach(([key, value]) => {
if (!invalidKeys.has(key)) filtered[key] = value;
});
return filtered as Cmcd;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need the warning here (and one per invalid key/message)? This could get very chatty on each event update.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ultimately I'm questioning the need for customKeys and validation/filtering when these are only set before start. If developers need to use the reporter to update customKeys maybe they should do that from the start and handle validation themselves. validateCmcdKeys adds to the hls.js payload from something that should only be done in debugging. I don't like the idea of that being a responsibility of hls.js.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed sanitizeCmcdData and added Validating custom keys section to API docs 1d60d9e

Comment thread src/config.ts Outdated
Comment on lines +99 to +107
customKeys?: Partial<Cmcd>;
})[];
loader?: (request: {
url: string;
method?: string;
headers?: Record<string, string>;
body?: BodyInit;
}) => Promise<{ status: number }>;
customKeys?: Partial<Cmcd>;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is Partial<Cmcd> too broad of a type definition for custom keys?

(Partial < type Cmcd = CmcdRequest & CmcdResponse & CmcdEvent >)

Are any parts allowed? Are standard keys even allowed to be used as custom ones?

It seems the part you want is:

[index: CmcdCustomKey]: CmcdCustomValue | undefined;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in dd78881

Comment thread src/controller/cmcd-controller.ts Outdated
data.su = this.buffering;
}

const { customKeys } = this.config.cmcd ?? {};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use the nullish coalescing operator (??) sparingly.

Suggested change
const { customKeys } = this.config.cmcd ?? {};
const customKeys = this.config.cmcd?.customKeys;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in df72d6e

Comment thread src/controller/cmcd-controller.ts Outdated

const { customKeys } = this.config.cmcd ?? {};
if (customKeys) {
Object.assign(data, this.sanitizeCmcdData(customKeys as Cmcd));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type coercion should not be needed here. Non-nullishcustomKeys should satisfy the input for sanitizeCmcdData

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in dd78881

@cotid-qualabs
Copy link
Copy Markdown
Author

Hi @robwalch , all the comments have been addressed

robwalch
robwalch previously approved these changes Jun 4, 2026
Comment thread src/controller/cmcd-controller.ts
@robwalch robwalch requested a review from littlespex June 4, 2026 17:01
Copy link
Copy Markdown
Collaborator

@robwalch robwalch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me. Holding on merging. I'd like to get feedback from @littlespex.

Here are some topics for discussion before taking the change:

  • Could there be issues with the api not being available until after report.start() (like custom fields not included in start event)?
  • Does the reporter have methods we would not want to expose? Should the exposed reporter just be an object that wraps update and recordEvent (as in the suggested interface CmcdControllerAPI)?
  • Is it spec compliant for ec to be an hls.js ErrorDetails value (ex: "manifestLoadError"), or should these be mapped to SVTA Standardized Player Error Codes?

@littlespex
Copy link
Copy Markdown
Collaborator

I'm still reviewing the latest round of changes, but here are some quick answers to the open questions:

  • Could there be issues with the api not being available until after report.start() (like custom fields not included in start event)?

It would only effect the first set of time-interval events, but yes, if you wanted custom fields in those events the callback would need to be run before start() is called.

  • Does the reporter have methods we would not want to expose? Should the exposed reporter just be an object that wraps update and recordEvent (as in the suggested interface CmcdControllerAPI)?

Yes. For example, all of the time interval events, start/stop/flush, are a part of the reporter's public interface and could cause inconsistencies in reporting if an end user were to call them directly.

  • Is it spec compliant for ec to be an hls.js ErrorDetails value (ex: "manifestLoadError"), or should these be mapped to SVTA Standardized Player Error Codes?

Ideally this would be a standard error code, but the spec doesn't explicitly enforce a format:

The namespace and formatting of this error code is left to the application.

That said, this does bring up the question of how an end user would be able to provide their own error code value. I'm still thinking this through.

Copy link
Copy Markdown
Collaborator

@robwalch robwalch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cotid-qualabs some suggestions to consider based on @littlespex's feedback. Thanks for all the work and responsiveness to feedback!

Comment thread src/controller/cmcd-controller.ts Outdated
Co-authored-by: Rob Walch <robwalch@users.noreply.github.com>
@cotid-qualabs
Copy link
Copy Markdown
Author

Thanks @robwalch and @littlespex ! Changes have been implemented 😄

Comment thread docs/API.md
- `loader`: An optional async function `(request) => Promise<{ status }>` used to deliver CMCD v2 event reports. When omitted, event reports are delivered via `fetch` (honoring the Hls `xhrSetup`/`fetchSetup` hooks). Only used when `eventTargets` is configured.
- `reporterCallback`: An optional `(reporter: CmcdCustomControllerAPI) => void` callback. Called once per `MANIFEST_LOADING`, before the reporter starts. Use it to seed custom CMCD keys or store the reference for firing custom events at runtime. Always use the most recently received reference, since a new source load yields a new instance.

The `CmcdCustomControllerAPI` facade exposes two methods:
Copy link
Copy Markdown
Collaborator

@robwalch robwalch Jun 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The `CmcdCustomControllerAPI` facade exposes two methods:
The `CmcdCustomReporter` exposes two methods:

I think this should be called CmcdCustomReporter. API is redundant and it isn't really a Controller. No one reading the docs needs to know it's a facade either.

Copy link
Copy Markdown
Collaborator

@robwalch robwalch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice to improve the name of CmcdCustomControllerAPI. CmcdCustomReporter?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Development

Successfully merging this pull request may close these issues.

Implement CMCD custom events & custom keys

3 participants