@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.js CHANGED
@@ -43,6 +43,7 @@ __export(src_exports, {
43
43
  ClientSampleSink: () => ClientSampleSink,
44
44
  CodecConsistencyValidator: () => CodecConsistencyValidator,
45
45
  Detectors: () => Detectors,
46
+ GEOHASH_CELL_SIZES: () => GEOHASH_CELL_SIZES,
46
47
  InMemorySink: () => InMemorySink,
47
48
  IssueFanOutDetector: () => IssueFanOutDetector,
48
49
  IssueFanOutTypes: () => IssueFanOutTypes,
@@ -103,6 +104,7 @@ __export(src_exports, {
103
104
  createP2pRemoteTrackResolverFactory: () => createP2pRemoteTrackResolverFactory,
104
105
  defaultCallSummaryConfig: () => defaultCallSummaryConfig,
105
106
  defaultClientHealthThresholds: () => defaultClientHealthThresholds,
107
+ geohash: () => geohash,
106
108
  isClientIssueResolutionEntry: () => isClientIssueResolutionEntry,
107
109
  issuePayloadAsString: () => issuePayloadAsString,
108
110
  mannKendall: () => mannKendall,
@@ -157,8 +159,8 @@ function createLogger(moduleName) {
157
159
  }
158
160
  }();
159
161
  }
160
- function setObserverLogger(logger10) {
161
- mainLogger = logger10;
162
+ function setObserverLogger(logger11) {
163
+ mainLogger = logger11;
162
164
  }
163
165
 
164
166
  // src/ObservedCall.ts
@@ -5706,7 +5708,54 @@ var ObserverConcurrentIssueDetector = class _ObserverConcurrentIssueDetector {
5706
5708
  }
5707
5709
  };
5708
5710
 
5711
+ // src/utils/geohash.ts
5712
+ var BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz";
5713
+ var GEOHASH_CELL_SIZES = [
5714
+ "",
5715
+ "~5000 km",
5716
+ "~1250 km",
5717
+ "~156 km",
5718
+ "~39 km",
5719
+ "~5 km",
5720
+ "~1.2 km",
5721
+ "~150 m"
5722
+ ];
5723
+ function geohash(location, precision = 3) {
5724
+ const { latitude, longitude } = location;
5725
+ if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return void 0;
5726
+ if (latitude < -90 || 90 < latitude) return void 0;
5727
+ if (longitude < -180 || 180 < longitude) return void 0;
5728
+ if (!Number.isFinite(precision) || precision < 1) return void 0;
5729
+ const chars = Math.min(12, Math.floor(precision));
5730
+ const latRange = { min: -90, max: 90 };
5731
+ const lonRange = { min: -180, max: 180 };
5732
+ let hash = "";
5733
+ let bits = 0;
5734
+ let value = 0;
5735
+ let isLongitude = true;
5736
+ while (hash.length < chars) {
5737
+ const range = isLongitude ? lonRange : latRange;
5738
+ const mid = (range.min + range.max) / 2;
5739
+ const coordinate = isLongitude ? longitude : latitude;
5740
+ if (mid <= coordinate) {
5741
+ value = (value << 1) + 1;
5742
+ range.min = mid;
5743
+ } else {
5744
+ value <<= 1;
5745
+ range.max = mid;
5746
+ }
5747
+ isLongitude = !isLongitude;
5748
+ if (++bits === 5) {
5749
+ hash += BASE32[value];
5750
+ bits = 0;
5751
+ value = 0;
5752
+ }
5753
+ }
5754
+ return hash;
5755
+ }
5756
+
5709
5757
  // src/detectors/ClientPopulationIssueDetector.ts
5758
+ var logger9 = createLogger("ClientPopulationIssueDetector");
5710
5759
  var ClientPopulationIssueTypes = {
5711
5760
  /** One issue type is concentrated on one client population while the rest of the fleet is fine. */
5712
5761
  clientPopulationIssue: "CLIENT_POPULATION_ISSUE"
@@ -5717,6 +5766,7 @@ var ClientPopulationIssueDetector = class _ClientPopulationIssueDetector {
5717
5766
  this._config = {
5718
5767
  issueTypes: [],
5719
5768
  groupBy: "browser",
5769
+ locationPrecision: 3,
5720
5770
  includeVersion: true,
5721
5771
  minPopulationSize: 20,
5722
5772
  minAffectedClients: 5,
@@ -5726,6 +5776,12 @@ var ClientPopulationIssueDetector = class _ClientPopulationIssueDetector {
5726
5776
  cooldownMs: 3e5,
5727
5777
  ...config
5728
5778
  };
5779
+ if (this._config.groupBy === "location" && !this._config.resolveClientLocation) {
5780
+ logger9.warn(
5781
+ "%s is configured with groupBy: 'location' but no resolveClientLocation, so no client can be placed and nothing will ever be detected",
5782
+ this.name
5783
+ );
5784
+ }
5729
5785
  for (const type of this._config.issueTypes) {
5730
5786
  this._observer.activeIssuesRegistry.addIssueTracker(type, this);
5731
5787
  }
@@ -5806,13 +5862,17 @@ var ClientPopulationIssueDetector = class _ClientPopulationIssueDetector {
5806
5862
  const key = `${population}:${issueType}`;
5807
5863
  if (now - (this._lastRaisedAt.get(key) ?? 0) < this._config.cooldownMs) continue;
5808
5864
  this._lastRaisedAt.set(key, now);
5865
+ const isLocation = this._config.groupBy === "location";
5809
5866
  this._observer.addIssue({
5810
5867
  type: ClientPopulationIssueTypes.clientPopulationIssue,
5811
5868
  timestamp: now,
5812
5869
  conclusion: {
5813
- faultDomain: "client-population",
5814
- 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})`,
5815
- recommendation: "this is not an SFU symptom \u2014 look at what those clients share: a recent release, a browser version, or shared/virtualised hardware",
5870
+ // Geography is not something endpoints own. A cluster of network symptoms in one
5871
+ // place is a path/transit story, so it is attributed to the infrastructure rather
5872
+ // than to a client build.
5873
+ faultDomain: isLocation ? "infrastructure" : "client-population",
5874
+ 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})`,
5875
+ 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",
5816
5876
  confidence: this._confidenceOf(rollup)
5817
5877
  },
5818
5878
  payload: { ...rollup }
@@ -5827,6 +5887,10 @@ var ClientPopulationIssueDetector = class _ClientPopulationIssueDetector {
5827
5887
  }
5828
5888
  /** `undefined` when the client never reported this attribute — see the class description. */
5829
5889
  _populationOf(client) {
5890
+ if (this._config.groupBy === "location") {
5891
+ const location = this._config.resolveClientLocation?.(client);
5892
+ return location ? geohash(location, this._config.locationPrecision) : void 0;
5893
+ }
5830
5894
  const attribute = client[this._config.groupBy];
5831
5895
  if (!attribute?.name) return void 0;
5832
5896
  return this._config.includeVersion && attribute.version ? `${attribute.name} ${attribute.version}` : attribute.name;
@@ -6547,7 +6611,7 @@ function mediaKindOf(mimeType) {
6547
6611
  }
6548
6612
 
6549
6613
  // src/Observer.ts
6550
- var logger9 = createLogger("Observer");
6614
+ var logger10 = createLogger("Observer");
6551
6615
  var Observer = class extends import_events6.EventEmitter {
6552
6616
  observedTURN = new ObservedTURN();
6553
6617
  observedCalls = /* @__PURE__ */ new Map();
@@ -6666,7 +6730,7 @@ var Observer = class extends import_events6.EventEmitter {
6666
6730
  break;
6667
6731
  }
6668
6732
  default: {
6669
- logger9.warn("Unknown detector name %s; skipping", name);
6733
+ logger10.warn("Unknown detector name %s; skipping", name);
6670
6734
  return this;
6671
6735
  }
6672
6736
  }
@@ -6751,7 +6815,7 @@ var Observer = class extends import_events6.EventEmitter {
6751
6815
  break;
6752
6816
  }
6753
6817
  if (!validator) {
6754
- logger9.warn("Unknown validator name %s; skipping", name);
6818
+ logger10.warn("Unknown validator name %s; skipping", name);
6755
6819
  return this;
6756
6820
  }
6757
6821
  this.validators.add(validator);
@@ -6782,7 +6846,7 @@ var Observer = class extends import_events6.EventEmitter {
6782
6846
  try {
6783
6847
  validator.cancel(reason);
6784
6848
  } catch (err) {
6785
- logger9.warn("Error cancelling validator %s: %o", validator.name, err);
6849
+ logger10.warn("Error cancelling validator %s: %o", validator.name, err);
6786
6850
  }
6787
6851
  }
6788
6852
  return matching.length;
@@ -6798,11 +6862,11 @@ var Observer = class extends import_events6.EventEmitter {
6798
6862
  */
6799
6863
  createObservedCall(settings, acceptCtx) {
6800
6864
  if (this.closed) {
6801
- logger9.warn("Attempted to create a call (callId: %s) on a closed observer", settings.callId);
6865
+ logger10.warn("Attempted to create a call (callId: %s) on a closed observer", settings.callId);
6802
6866
  return void 0;
6803
6867
  }
6804
6868
  if (this.observedCalls.has(settings.callId)) {
6805
- logger9.warn("Observed Call with id %s already exists; returning the existing instance", settings.callId);
6869
+ logger10.warn("Observed Call with id %s already exists; returning the existing instance", settings.callId);
6806
6870
  return this.observedCalls.get(settings.callId);
6807
6871
  }
6808
6872
  const callSettings = {
@@ -6840,11 +6904,11 @@ var Observer = class extends import_events6.EventEmitter {
6840
6904
  }
6841
6905
  createObservedMediasoupRouter(settings) {
6842
6906
  if (this.closed) {
6843
- logger9.warn("Attempted to create mediasoup router (id: %d) on a closed observer", settings.router.id);
6907
+ logger10.warn("Attempted to create mediasoup router (id: %d) on a closed observer", settings.router.id);
6844
6908
  return void 0;
6845
6909
  }
6846
6910
  if (this.observedMediasoupRouters.has(settings.router.id)) {
6847
- logger9.warn("Observed Mediasoup Router (id %s) already exists; returning the existing instance", settings.router.id);
6911
+ logger10.warn("Observed Mediasoup Router (id %s) already exists; returning the existing instance", settings.router.id);
6848
6912
  return this.observedMediasoupRouters.get(settings.router.id);
6849
6913
  }
6850
6914
  const observedMediasoupRouter = new ObservedMediasoupRouter(settings);
@@ -6875,7 +6939,7 @@ var Observer = class extends import_events6.EventEmitter {
6875
6939
  }
6876
6940
  close() {
6877
6941
  if (this.closed) {
6878
- return logger9.debug("Attempted to close twice");
6942
+ return logger10.debug("Attempted to close twice");
6879
6943
  }
6880
6944
  this.closed = true;
6881
6945
  for (const call of [...this.observedCalls.values()]) call.close();
@@ -6893,7 +6957,7 @@ var Observer = class extends import_events6.EventEmitter {
6893
6957
  try {
6894
6958
  this.acceptMiddlewares.process({ sample, context });
6895
6959
  } catch (err) {
6896
- logger9.warn("An accept middleware threw; dropping the sample. %o", err);
6960
+ logger10.warn("An accept middleware threw; dropping the sample. %o", err);
6897
6961
  }
6898
6962
  if (!sample.callId) {
6899
6963
  this._notify("sample-rejected", { ...this.eventScope, reason: "missing-callId", sample });
@@ -6933,7 +6997,7 @@ var Observer = class extends import_events6.EventEmitter {
6933
6997
  try {
6934
6998
  validator.update();
6935
6999
  } catch (err) {
6936
- logger9.warn("Error running validator %s: %o", validator.name, err);
7000
+ logger10.warn("Error running validator %s: %o", validator.name, err);
6937
7001
  }
6938
7002
  }
6939
7003
  this._notify("observer-updated", { ...this.eventScope });
@@ -7247,21 +7311,56 @@ var RemoteTrackResolver = class {
7247
7311
  const onOutboundRemoved = (p) => {
7248
7312
  if (p.observedCall === this.observedCall) this._removeOutboundTrack(p.observedOutboundTrack);
7249
7313
  };
7314
+ const onInboundUpdated = (p) => {
7315
+ if (p.observedCall !== this.observedCall) return;
7316
+ if (this._pendingInboundTracks.has(p.observedInboundTrack)) this._addInboundTrack(p.observedInboundTrack);
7317
+ };
7318
+ const onOutboundUpdated = (p) => {
7319
+ if (p.observedCall !== this.observedCall) return;
7320
+ if (this._pendingOutboundTracks.has(p.observedOutboundTrack)) this._addOutboundTrack(p.observedOutboundTrack);
7321
+ };
7250
7322
  observedCall.once("close", () => {
7251
7323
  observer.off("inbound-track-added", onInboundAdded);
7252
7324
  observer.off("inbound-track-removed", onInboundRemoved);
7253
7325
  observer.off("outbound-track-added", onOutboundAdded);
7254
7326
  observer.off("outbound-track-removed", onOutboundRemoved);
7327
+ observer.off("inbound-track-updated", onInboundUpdated);
7328
+ observer.off("outbound-track-updated", onOutboundUpdated);
7329
+ this._pendingInboundTracks.clear();
7330
+ this._pendingOutboundTracks.clear();
7255
7331
  });
7256
7332
  observer.on("inbound-track-added", onInboundAdded);
7257
7333
  observer.on("inbound-track-removed", onInboundRemoved);
7258
7334
  observer.on("outbound-track-added", onOutboundAdded);
7259
7335
  observer.on("outbound-track-removed", onOutboundRemoved);
7336
+ observer.on("inbound-track-updated", onInboundUpdated);
7337
+ observer.on("outbound-track-updated", onOutboundUpdated);
7260
7338
  }
7261
7339
  observedCall;
7262
7340
  resolvers;
7263
7341
  _publisherIdToOutboundTrack = /* @__PURE__ */ new Map();
7264
7342
  _subscriberIdToInboundTrack = /* @__PURE__ */ new Map();
7343
+ /**
7344
+ * Tracks whose publisher id the strategy could not resolve **yet**.
7345
+ *
7346
+ * A track announces itself once, but its `attachments` are replaced on every sample, so a key that
7347
+ * is missing from the first sample can appear on the second — and a strategy backed by an
7348
+ * application's own mapping (a server-side `ssrc -> producerId` table, say) is inherently racy
7349
+ * against sample arrival. Resolving only at `*-track-added` meant losing those tracks for their
7350
+ * entire life, silently: an unresolvable outbound track never even reaches
7351
+ * `unconsumedOutboundTracks`, so it is invisible to `UnconsumedTrackDetector` too.
7352
+ *
7353
+ * So they wait here and are retried on their own `*-track-updated`, i.e. exactly when new stats
7354
+ * arrived for them. A track leaves on its first successful resolution or on removal, which makes
7355
+ * a linked track cost one `Set.has` per update and bounds these sets by the unresolved tracks
7356
+ * alive right now.
7357
+ */
7358
+ _pendingInboundTracks = /* @__PURE__ */ new Set();
7359
+ _pendingOutboundTracks = /* @__PURE__ */ new Set();
7360
+ /** Tracks still waiting for a resolvable publisher id. Diagnostics; normally both are empty. */
7361
+ get pendingTrackCounts() {
7362
+ return { inbound: this._pendingInboundTracks.size, outbound: this._pendingOutboundTracks.size };
7363
+ }
7265
7364
  /** The published (outbound) track for a publisher id, if any. */
7266
7365
  getOutboundTrackByPublisherId(publisherId) {
7267
7366
  return this._publisherIdToOutboundTrack.get(publisherId);
@@ -7279,7 +7378,11 @@ var RemoteTrackResolver = class {
7279
7378
  }
7280
7379
  _addInboundTrack(inboundTrack) {
7281
7380
  const publisherId = this.resolvers.resolveInboundTrackPublisherId(inboundTrack);
7282
- if (!publisherId) return;
7381
+ if (!publisherId) {
7382
+ this._pendingInboundTracks.add(inboundTrack);
7383
+ return;
7384
+ }
7385
+ this._pendingInboundTracks.delete(inboundTrack);
7283
7386
  const subscriberId = this.resolvers.resolveInboundTrackSubscriberId?.(inboundTrack);
7284
7387
  if (subscriberId) {
7285
7388
  this._subscriberIdToInboundTrack.set(subscriberId, inboundTrack);
@@ -7291,6 +7394,7 @@ var RemoteTrackResolver = class {
7291
7394
  this.observedCall.unconsumedOutboundTracks.delete(outboundTrack);
7292
7395
  }
7293
7396
  _removeInboundTrack(inboundTrack) {
7397
+ this._pendingInboundTracks.delete(inboundTrack);
7294
7398
  const outboundTrack = inboundTrack.remoteOutboundTrack;
7295
7399
  outboundTrack?.remoteInboundTracks.delete(inboundTrack);
7296
7400
  inboundTrack.remoteOutboundTrack = void 0;
@@ -7304,7 +7408,11 @@ var RemoteTrackResolver = class {
7304
7408
  }
7305
7409
  _addOutboundTrack(outboundTrack) {
7306
7410
  const publisherId = this.resolvers.resolveOutboundTrackPublisherId(outboundTrack);
7307
- if (!publisherId) return;
7411
+ if (!publisherId) {
7412
+ this._pendingOutboundTracks.add(outboundTrack);
7413
+ return;
7414
+ }
7415
+ this._pendingOutboundTracks.delete(outboundTrack);
7308
7416
  this._publisherIdToOutboundTrack.set(publisherId, outboundTrack);
7309
7417
  this.observedCall.unconsumedOutboundTracks.add(outboundTrack);
7310
7418
  for (const observedClient of this.observedCall.observedClients.values()) {
@@ -7319,6 +7427,7 @@ var RemoteTrackResolver = class {
7319
7427
  }
7320
7428
  }
7321
7429
  _removeOutboundTrack(outboundTrack) {
7430
+ this._pendingOutboundTracks.delete(outboundTrack);
7322
7431
  const publisherId = this.resolvers.resolveOutboundTrackPublisherId(outboundTrack);
7323
7432
  if (publisherId && this._publisherIdToOutboundTrack.get(publisherId) === outboundTrack) {
7324
7433
  this._publisherIdToOutboundTrack.delete(publisherId);
@@ -7371,6 +7480,7 @@ function createP2pRemoteTrackResolverFactory() {
7371
7480
  ClientSampleSink,
7372
7481
  CodecConsistencyValidator,
7373
7482
  Detectors,
7483
+ GEOHASH_CELL_SIZES,
7374
7484
  InMemorySink,
7375
7485
  IssueFanOutDetector,
7376
7486
  IssueFanOutTypes,
@@ -7431,6 +7541,7 @@ function createP2pRemoteTrackResolverFactory() {
7431
7541
  createP2pRemoteTrackResolverFactory,
7432
7542
  defaultCallSummaryConfig,
7433
7543
  defaultClientHealthThresholds,
7544
+ geohash,
7434
7545
  isClientIssueResolutionEntry,
7435
7546
  issuePayloadAsString,
7436
7547
  mannKendall,