@observertc/observer-js 1.0.0-beta.18 → 1.0.0-beta.21

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.
package/dist/index.mjs CHANGED
@@ -37,8 +37,8 @@ function createLogger(moduleName) {
37
37
  }
38
38
  }();
39
39
  }
40
- function setObserverLogger(logger10) {
41
- mainLogger = logger10;
40
+ function setObserverLogger(logger11) {
41
+ mainLogger = logger11;
42
42
  }
43
43
 
44
44
  // src/ObservedCall.ts
@@ -5586,7 +5586,54 @@ var ObserverConcurrentIssueDetector = class _ObserverConcurrentIssueDetector {
5586
5586
  }
5587
5587
  };
5588
5588
 
5589
+ // src/utils/geohash.ts
5590
+ var BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz";
5591
+ var GEOHASH_CELL_SIZES = [
5592
+ "",
5593
+ "~5000 km",
5594
+ "~1250 km",
5595
+ "~156 km",
5596
+ "~39 km",
5597
+ "~5 km",
5598
+ "~1.2 km",
5599
+ "~150 m"
5600
+ ];
5601
+ function geohash(location, precision = 3) {
5602
+ const { latitude, longitude } = location;
5603
+ if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return void 0;
5604
+ if (latitude < -90 || 90 < latitude) return void 0;
5605
+ if (longitude < -180 || 180 < longitude) return void 0;
5606
+ if (!Number.isFinite(precision) || precision < 1) return void 0;
5607
+ const chars = Math.min(12, Math.floor(precision));
5608
+ const latRange = { min: -90, max: 90 };
5609
+ const lonRange = { min: -180, max: 180 };
5610
+ let hash = "";
5611
+ let bits = 0;
5612
+ let value = 0;
5613
+ let isLongitude = true;
5614
+ while (hash.length < chars) {
5615
+ const range = isLongitude ? lonRange : latRange;
5616
+ const mid = (range.min + range.max) / 2;
5617
+ const coordinate = isLongitude ? longitude : latitude;
5618
+ if (mid <= coordinate) {
5619
+ value = (value << 1) + 1;
5620
+ range.min = mid;
5621
+ } else {
5622
+ value <<= 1;
5623
+ range.max = mid;
5624
+ }
5625
+ isLongitude = !isLongitude;
5626
+ if (++bits === 5) {
5627
+ hash += BASE32[value];
5628
+ bits = 0;
5629
+ value = 0;
5630
+ }
5631
+ }
5632
+ return hash;
5633
+ }
5634
+
5589
5635
  // src/detectors/ClientPopulationIssueDetector.ts
5636
+ var logger9 = createLogger("ClientPopulationIssueDetector");
5590
5637
  var ClientPopulationIssueTypes = {
5591
5638
  /** One issue type is concentrated on one client population while the rest of the fleet is fine. */
5592
5639
  clientPopulationIssue: "CLIENT_POPULATION_ISSUE"
@@ -5597,6 +5644,7 @@ var ClientPopulationIssueDetector = class _ClientPopulationIssueDetector {
5597
5644
  this._config = {
5598
5645
  issueTypes: [],
5599
5646
  groupBy: "browser",
5647
+ locationPrecision: 3,
5600
5648
  includeVersion: true,
5601
5649
  minPopulationSize: 20,
5602
5650
  minAffectedClients: 5,
@@ -5606,6 +5654,12 @@ var ClientPopulationIssueDetector = class _ClientPopulationIssueDetector {
5606
5654
  cooldownMs: 3e5,
5607
5655
  ...config
5608
5656
  };
5657
+ if (this._config.groupBy === "location" && !this._config.resolveClientLocation) {
5658
+ logger9.warn(
5659
+ "%s is configured with groupBy: 'location' but no resolveClientLocation, so no client can be placed and nothing will ever be detected",
5660
+ this.name
5661
+ );
5662
+ }
5609
5663
  for (const type of this._config.issueTypes) {
5610
5664
  this._observer.activeIssuesRegistry.addIssueTracker(type, this);
5611
5665
  }
@@ -5686,13 +5740,17 @@ var ClientPopulationIssueDetector = class _ClientPopulationIssueDetector {
5686
5740
  const key = `${population}:${issueType}`;
5687
5741
  if (now - (this._lastRaisedAt.get(key) ?? 0) < this._config.cooldownMs) continue;
5688
5742
  this._lastRaisedAt.set(key, now);
5743
+ const isLocation = this._config.groupBy === "location";
5689
5744
  this._observer.addIssue({
5690
5745
  type: ClientPopulationIssueTypes.clientPopulationIssue,
5691
5746
  timestamp: now,
5692
5747
  conclusion: {
5693
- faultDomain: "client-population",
5694
- summary: `'${issueType}' is ${this._riskText(rollup.relativeRisk)} more likely on ${population} than on the rest of the fleet (${rollup.affectedClients}/${rollup.clients} vs ${rollup.controlAffectedClients}/${rollup.controlClients})`,
5695
- recommendation: "this is not an SFU symptom \u2014 look at what those clients share: a recent release, a browser version, or shared/virtualised hardware",
5748
+ // Geography is not something endpoints own. A cluster of network symptoms in one
5749
+ // place is a path/transit story, so it is attributed to the infrastructure rather
5750
+ // than to a client build.
5751
+ faultDomain: isLocation ? "infrastructure" : "client-population",
5752
+ summary: `'${issueType}' is ${this._riskText(rollup.relativeRisk)} more likely ${isLocation ? `in cell ${population}` : `on ${population}`} than across the rest of the fleet (${rollup.affectedClients}/${rollup.clients} vs ${rollup.controlAffectedClients}/${rollup.controlClients})`,
5753
+ recommendation: isLocation ? "look at the path into that region \u2014 ISP, transit or peering \u2014 but first rule out the servers those clients share, since a region often maps onto one SFU or TURN deployment" : "this is not an SFU symptom \u2014 look at what those clients share: a recent release, a browser version, or shared/virtualised hardware",
5696
5754
  confidence: this._confidenceOf(rollup)
5697
5755
  },
5698
5756
  payload: { ...rollup }
@@ -5707,6 +5765,10 @@ var ClientPopulationIssueDetector = class _ClientPopulationIssueDetector {
5707
5765
  }
5708
5766
  /** `undefined` when the client never reported this attribute — see the class description. */
5709
5767
  _populationOf(client) {
5768
+ if (this._config.groupBy === "location") {
5769
+ const location = this._config.resolveClientLocation?.(client);
5770
+ return location ? geohash(location, this._config.locationPrecision) : void 0;
5771
+ }
5710
5772
  const attribute = client[this._config.groupBy];
5711
5773
  if (!attribute?.name) return void 0;
5712
5774
  return this._config.includeVersion && attribute.version ? `${attribute.name} ${attribute.version}` : attribute.name;
@@ -6427,7 +6489,7 @@ function mediaKindOf(mimeType) {
6427
6489
  }
6428
6490
 
6429
6491
  // src/Observer.ts
6430
- var logger9 = createLogger("Observer");
6492
+ var logger10 = createLogger("Observer");
6431
6493
  var Observer = class extends EventEmitter6 {
6432
6494
  observedTURN = new ObservedTURN();
6433
6495
  observedCalls = /* @__PURE__ */ new Map();
@@ -6546,7 +6608,7 @@ var Observer = class extends EventEmitter6 {
6546
6608
  break;
6547
6609
  }
6548
6610
  default: {
6549
- logger9.warn("Unknown detector name %s; skipping", name);
6611
+ logger10.warn("Unknown detector name %s; skipping", name);
6550
6612
  return this;
6551
6613
  }
6552
6614
  }
@@ -6631,7 +6693,7 @@ var Observer = class extends EventEmitter6 {
6631
6693
  break;
6632
6694
  }
6633
6695
  if (!validator) {
6634
- logger9.warn("Unknown validator name %s; skipping", name);
6696
+ logger10.warn("Unknown validator name %s; skipping", name);
6635
6697
  return this;
6636
6698
  }
6637
6699
  this.validators.add(validator);
@@ -6662,7 +6724,7 @@ var Observer = class extends EventEmitter6 {
6662
6724
  try {
6663
6725
  validator.cancel(reason);
6664
6726
  } catch (err) {
6665
- logger9.warn("Error cancelling validator %s: %o", validator.name, err);
6727
+ logger10.warn("Error cancelling validator %s: %o", validator.name, err);
6666
6728
  }
6667
6729
  }
6668
6730
  return matching.length;
@@ -6678,11 +6740,11 @@ var Observer = class extends EventEmitter6 {
6678
6740
  */
6679
6741
  createObservedCall(settings, acceptCtx) {
6680
6742
  if (this.closed) {
6681
- logger9.warn("Attempted to create a call (callId: %s) on a closed observer", settings.callId);
6743
+ logger10.warn("Attempted to create a call (callId: %s) on a closed observer", settings.callId);
6682
6744
  return void 0;
6683
6745
  }
6684
6746
  if (this.observedCalls.has(settings.callId)) {
6685
- logger9.warn("Observed Call with id %s already exists; returning the existing instance", settings.callId);
6747
+ logger10.warn("Observed Call with id %s already exists; returning the existing instance", settings.callId);
6686
6748
  return this.observedCalls.get(settings.callId);
6687
6749
  }
6688
6750
  const callSettings = {
@@ -6720,11 +6782,11 @@ var Observer = class extends EventEmitter6 {
6720
6782
  }
6721
6783
  createObservedMediasoupRouter(settings) {
6722
6784
  if (this.closed) {
6723
- logger9.warn("Attempted to create mediasoup router (id: %d) on a closed observer", settings.router.id);
6785
+ logger10.warn("Attempted to create mediasoup router (id: %d) on a closed observer", settings.router.id);
6724
6786
  return void 0;
6725
6787
  }
6726
6788
  if (this.observedMediasoupRouters.has(settings.router.id)) {
6727
- logger9.warn("Observed Mediasoup Router (id %s) already exists; returning the existing instance", settings.router.id);
6789
+ logger10.warn("Observed Mediasoup Router (id %s) already exists; returning the existing instance", settings.router.id);
6728
6790
  return this.observedMediasoupRouters.get(settings.router.id);
6729
6791
  }
6730
6792
  const observedMediasoupRouter = new ObservedMediasoupRouter(settings);
@@ -6755,7 +6817,7 @@ var Observer = class extends EventEmitter6 {
6755
6817
  }
6756
6818
  close() {
6757
6819
  if (this.closed) {
6758
- return logger9.debug("Attempted to close twice");
6820
+ return logger10.debug("Attempted to close twice");
6759
6821
  }
6760
6822
  this.closed = true;
6761
6823
  for (const call of [...this.observedCalls.values()]) call.close();
@@ -6773,7 +6835,7 @@ var Observer = class extends EventEmitter6 {
6773
6835
  try {
6774
6836
  this.acceptMiddlewares.process({ sample, context });
6775
6837
  } catch (err) {
6776
- logger9.warn("An accept middleware threw; dropping the sample. %o", err);
6838
+ logger10.warn("An accept middleware threw; dropping the sample. %o", err);
6777
6839
  }
6778
6840
  if (!sample.callId) {
6779
6841
  this._notify("sample-rejected", { ...this.eventScope, reason: "missing-callId", sample });
@@ -6813,7 +6875,7 @@ var Observer = class extends EventEmitter6 {
6813
6875
  try {
6814
6876
  validator.update();
6815
6877
  } catch (err) {
6816
- logger9.warn("Error running validator %s: %o", validator.name, err);
6878
+ logger10.warn("Error running validator %s: %o", validator.name, err);
6817
6879
  }
6818
6880
  }
6819
6881
  this._notify("observer-updated", { ...this.eventScope });
@@ -7127,21 +7189,56 @@ var RemoteTrackResolver = class {
7127
7189
  const onOutboundRemoved = (p) => {
7128
7190
  if (p.observedCall === this.observedCall) this._removeOutboundTrack(p.observedOutboundTrack);
7129
7191
  };
7192
+ const onInboundUpdated = (p) => {
7193
+ if (p.observedCall !== this.observedCall) return;
7194
+ if (this._pendingInboundTracks.has(p.observedInboundTrack)) this._addInboundTrack(p.observedInboundTrack);
7195
+ };
7196
+ const onOutboundUpdated = (p) => {
7197
+ if (p.observedCall !== this.observedCall) return;
7198
+ if (this._pendingOutboundTracks.has(p.observedOutboundTrack)) this._addOutboundTrack(p.observedOutboundTrack);
7199
+ };
7130
7200
  observedCall.once("close", () => {
7131
7201
  observer.off("inbound-track-added", onInboundAdded);
7132
7202
  observer.off("inbound-track-removed", onInboundRemoved);
7133
7203
  observer.off("outbound-track-added", onOutboundAdded);
7134
7204
  observer.off("outbound-track-removed", onOutboundRemoved);
7205
+ observer.off("inbound-track-updated", onInboundUpdated);
7206
+ observer.off("outbound-track-updated", onOutboundUpdated);
7207
+ this._pendingInboundTracks.clear();
7208
+ this._pendingOutboundTracks.clear();
7135
7209
  });
7136
7210
  observer.on("inbound-track-added", onInboundAdded);
7137
7211
  observer.on("inbound-track-removed", onInboundRemoved);
7138
7212
  observer.on("outbound-track-added", onOutboundAdded);
7139
7213
  observer.on("outbound-track-removed", onOutboundRemoved);
7214
+ observer.on("inbound-track-updated", onInboundUpdated);
7215
+ observer.on("outbound-track-updated", onOutboundUpdated);
7140
7216
  }
7141
7217
  observedCall;
7142
7218
  resolvers;
7143
7219
  _publisherIdToOutboundTrack = /* @__PURE__ */ new Map();
7144
7220
  _subscriberIdToInboundTrack = /* @__PURE__ */ new Map();
7221
+ /**
7222
+ * Tracks whose publisher id the strategy could not resolve **yet**.
7223
+ *
7224
+ * A track announces itself once, but its `attachments` are replaced on every sample, so a key that
7225
+ * is missing from the first sample can appear on the second — and a strategy backed by an
7226
+ * application's own mapping (a server-side `ssrc -> producerId` table, say) is inherently racy
7227
+ * against sample arrival. Resolving only at `*-track-added` meant losing those tracks for their
7228
+ * entire life, silently: an unresolvable outbound track never even reaches
7229
+ * `unconsumedOutboundTracks`, so it is invisible to `UnconsumedTrackDetector` too.
7230
+ *
7231
+ * So they wait here and are retried on their own `*-track-updated`, i.e. exactly when new stats
7232
+ * arrived for them. A track leaves on its first successful resolution or on removal, which makes
7233
+ * a linked track cost one `Set.has` per update and bounds these sets by the unresolved tracks
7234
+ * alive right now.
7235
+ */
7236
+ _pendingInboundTracks = /* @__PURE__ */ new Set();
7237
+ _pendingOutboundTracks = /* @__PURE__ */ new Set();
7238
+ /** Tracks still waiting for a resolvable publisher id. Diagnostics; normally both are empty. */
7239
+ get pendingTrackCounts() {
7240
+ return { inbound: this._pendingInboundTracks.size, outbound: this._pendingOutboundTracks.size };
7241
+ }
7145
7242
  /** The published (outbound) track for a publisher id, if any. */
7146
7243
  getOutboundTrackByPublisherId(publisherId) {
7147
7244
  return this._publisherIdToOutboundTrack.get(publisherId);
@@ -7159,7 +7256,11 @@ var RemoteTrackResolver = class {
7159
7256
  }
7160
7257
  _addInboundTrack(inboundTrack) {
7161
7258
  const publisherId = this.resolvers.resolveInboundTrackPublisherId(inboundTrack);
7162
- if (!publisherId) return;
7259
+ if (!publisherId) {
7260
+ this._pendingInboundTracks.add(inboundTrack);
7261
+ return;
7262
+ }
7263
+ this._pendingInboundTracks.delete(inboundTrack);
7163
7264
  const subscriberId = this.resolvers.resolveInboundTrackSubscriberId?.(inboundTrack);
7164
7265
  if (subscriberId) {
7165
7266
  this._subscriberIdToInboundTrack.set(subscriberId, inboundTrack);
@@ -7171,6 +7272,7 @@ var RemoteTrackResolver = class {
7171
7272
  this.observedCall.unconsumedOutboundTracks.delete(outboundTrack);
7172
7273
  }
7173
7274
  _removeInboundTrack(inboundTrack) {
7275
+ this._pendingInboundTracks.delete(inboundTrack);
7174
7276
  const outboundTrack = inboundTrack.remoteOutboundTrack;
7175
7277
  outboundTrack?.remoteInboundTracks.delete(inboundTrack);
7176
7278
  inboundTrack.remoteOutboundTrack = void 0;
@@ -7184,7 +7286,11 @@ var RemoteTrackResolver = class {
7184
7286
  }
7185
7287
  _addOutboundTrack(outboundTrack) {
7186
7288
  const publisherId = this.resolvers.resolveOutboundTrackPublisherId(outboundTrack);
7187
- if (!publisherId) return;
7289
+ if (!publisherId) {
7290
+ this._pendingOutboundTracks.add(outboundTrack);
7291
+ return;
7292
+ }
7293
+ this._pendingOutboundTracks.delete(outboundTrack);
7188
7294
  this._publisherIdToOutboundTrack.set(publisherId, outboundTrack);
7189
7295
  this.observedCall.unconsumedOutboundTracks.add(outboundTrack);
7190
7296
  for (const observedClient of this.observedCall.observedClients.values()) {
@@ -7199,6 +7305,7 @@ var RemoteTrackResolver = class {
7199
7305
  }
7200
7306
  }
7201
7307
  _removeOutboundTrack(outboundTrack) {
7308
+ this._pendingOutboundTracks.delete(outboundTrack);
7202
7309
  const publisherId = this.resolvers.resolveOutboundTrackPublisherId(outboundTrack);
7203
7310
  if (publisherId && this._publisherIdToOutboundTrack.get(publisherId) === outboundTrack) {
7204
7311
  this._publisherIdToOutboundTrack.delete(publisherId);
@@ -7250,6 +7357,7 @@ export {
7250
7357
  ClientSampleSink,
7251
7358
  CodecConsistencyValidator,
7252
7359
  Detectors,
7360
+ GEOHASH_CELL_SIZES,
7253
7361
  InMemorySink,
7254
7362
  IssueFanOutDetector,
7255
7363
  IssueFanOutTypes,
@@ -7310,6 +7418,7 @@ export {
7310
7418
  createP2pRemoteTrackResolverFactory,
7311
7419
  defaultCallSummaryConfig,
7312
7420
  defaultClientHealthThresholds,
7421
+ geohash,
7313
7422
  isClientIssueResolutionEntry,
7314
7423
  issuePayloadAsString,
7315
7424
  mannKendall,