• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

grpc / grpc-java / #20365

27 Jul 2026 06:04AM UTC coverage: 89.199% (+0.07%) from 89.125%
#20365

push

github

web-flow
core: Implement LB Delay Observability (Proposal A121) (#12807)

This PR implements **Attempt-Level RPC Delay Observability** across the core channel transport, built-in load balancers, xDS policies, and the OpenTelemetry telemetry plugin, aligned with [gRPC Proposal A121](https://github.com/grpc/proposal/pull/556).

38276 of 42911 relevant lines covered (89.2%)

0.89 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.83
/../core/src/main/java/io/grpc/internal/DelayedClientTransport.java
1
/*
2
 * Copyright 2015 The gRPC Authors
3
 *
4
 * Licensed under the Apache License, Version 2.0 (the "License");
5
 * you may not use this file except in compliance with the License.
6
 * You may obtain a copy of the License at
7
 *
8
 *     http://www.apache.org/licenses/LICENSE-2.0
9
 *
10
 * Unless required by applicable law or agreed to in writing, software
11
 * distributed under the License is distributed on an "AS IS" BASIS,
12
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
 * See the License for the specific language governing permissions and
14
 * limitations under the License.
15
 */
16

17
package io.grpc.internal;
18

19
import com.google.common.annotations.VisibleForTesting;
20
import com.google.common.util.concurrent.ListenableFuture;
21
import com.google.common.util.concurrent.SettableFuture;
22
import com.google.errorprone.annotations.concurrent.GuardedBy;
23
import io.grpc.CallOptions;
24
import io.grpc.ClientStreamTracer;
25
import io.grpc.Context;
26
import io.grpc.InternalChannelz.SocketStats;
27
import io.grpc.InternalLogId;
28
import io.grpc.LoadBalancer.PickResult;
29
import io.grpc.LoadBalancer.PickSubchannelArgs;
30
import io.grpc.LoadBalancer.SubchannelPicker;
31
import io.grpc.Metadata;
32
import io.grpc.MethodDescriptor;
33
import io.grpc.Status;
34
import io.grpc.SynchronizationContext;
35
import io.grpc.internal.ClientStreamListener.RpcProgress;
36
import java.util.ArrayList;
37
import java.util.Collection;
38
import java.util.Collections;
39
import java.util.LinkedHashSet;
40
import java.util.Objects;
41
import java.util.concurrent.Executor;
42
import javax.annotation.Nonnull;
43
import javax.annotation.Nullable;
44

45
/**
46
 * A client transport that queues requests before a real transport is available. When {@link
47
 * #reprocess} is called, this class applies the provided {@link SubchannelPicker} to pick a
48
 * transport for each pending stream.
49
 *
50
 * <p>This transport owns every stream that it has created until a real transport has been picked
51
 * for that stream, at which point the ownership of the stream is transferred to the real transport,
52
 * thus the delayed transport stops owning the stream.
53
 */
54
final class DelayedClientTransport implements ManagedClientTransport {
55
  // lazily allocated, since it is infrequently used.
56
  private final InternalLogId logId =
1✔
57
      InternalLogId.allocate(DelayedClientTransport.class, /*details=*/ null);
1✔
58

59
  private final Object lock = new Object();
1✔
60

61
  private final Executor defaultAppExecutor;
62
  private final SynchronizationContext syncContext;
63

64
  private Runnable reportTransportInUse;
65
  private Runnable reportTransportNotInUse;
66
  private Runnable reportTransportTerminated;
67
  private Listener listener;
68

69
  @Nonnull
1✔
70
  @GuardedBy("lock")
71
  private Collection<PendingStream> pendingStreams = new LinkedHashSet<>();
72

73
  /** Immutable state needed for picking. 'lock' must be held for writing. */
74
  private volatile PickerState pickerState = new PickerState(null, null);
1✔
75

76
  /**
77
   * Creates a new delayed transport.
78
   *
79
   * @param defaultAppExecutor pending streams will create real streams and run buffered operations
80
   *        in an application executor, which will be this executor, unless there is on provided in
81
   *        {@link CallOptions}.
82
   * @param syncContext all listener callbacks of the delayed transport will be run from this
83
   *        SynchronizationContext.
84
   */
85
  DelayedClientTransport(Executor defaultAppExecutor, SynchronizationContext syncContext) {
1✔
86
    this.defaultAppExecutor = defaultAppExecutor;
1✔
87
    this.syncContext = syncContext;
1✔
88
  }
1✔
89

90
  @Override
91
  public final Runnable start(final Listener listener) {
92
    this.listener = listener;
1✔
93
    reportTransportInUse = new Runnable() {
1✔
94
        @Override
95
        public void run() {
96
          listener.transportInUse(true);
1✔
97
        }
1✔
98
      };
99
    reportTransportNotInUse = new Runnable() {
1✔
100
        @Override
101
        public void run() {
102
          listener.transportInUse(false);
1✔
103
        }
1✔
104
      };
105
    reportTransportTerminated = new Runnable() {
1✔
106
        @Override
107
        public void run() {
108
          listener.transportTerminated();
1✔
109
        }
1✔
110
      };
111
    return null;
1✔
112
  }
113

114
  /**
115
   * If a {@link SubchannelPicker} is being, or has been provided via {@link #reprocess}, the last
116
   * picker will be consulted.
117
   *
118
   * <p>Otherwise, if the delayed transport is not shutdown, then a {@link PendingStream} is
119
   * returned; if the transport is shutdown, then a {@link FailingClientStream} is returned.
120
   */
121
  @Override
122
  public final ClientStream newStream(
123
      MethodDescriptor<?, ?> method, Metadata headers, CallOptions callOptions,
124
      ClientStreamTracer[] tracers) {
125
    try {
126
      PickSubchannelArgs args = new PickSubchannelArgsImpl(
1✔
127
          method, headers, callOptions, new PickDetailsConsumerImpl(tracers));
128
      PickerState state = pickerState;
1✔
129
      while (true) {
130
        if (state.shutdownStatus != null) {
1✔
131
          return new FailingClientStream(state.shutdownStatus, tracers);
1✔
132
        }
133
        PickResult pickResult = null;
1✔
134
        if (state.lastPicker != null) {
1✔
135
          pickResult = state.lastPicker.pickSubchannel(args);
1✔
136
          callOptions = args.getCallOptions();
1✔
137
          // User code provided authority takes precedence over the LB provided one.
138
          if (callOptions.getAuthority() == null
1✔
139
              && pickResult.getAuthorityOverride() != null) {
1✔
140
            callOptions = callOptions.withAuthority(pickResult.getAuthorityOverride());
1✔
141
          }
142
          ClientTransport transport = GrpcUtil.getTransportFromPickResult(pickResult,
1✔
143
              callOptions.isWaitForReady());
1✔
144
          if (transport != null) {
1✔
145
            ClientStream stream = transport.newStream(
1✔
146
                args.getMethodDescriptor(), args.getHeaders(), callOptions,
1✔
147
                tracers);
148
            // User code provided authority takes precedence over the LB provided one; this will be
149
            // overwritten by ClientCallImpl if the application sets an authority override
150
            if (pickResult.getAuthorityOverride() != null) {
1✔
151
              stream.setAuthority(pickResult.getAuthorityOverride());
1✔
152
            }
153
            return stream;
1✔
154
          }
155
        }
156
        // This picker's conclusion is "buffer".  If there hasn't been a newer picker set (possible
157
        // race with reprocess()), we will buffer the RPC.  Otherwise, will try with the new picker.
158
        synchronized (lock) {
1✔
159
          PickerState newerState = pickerState;
1✔
160
          if (state == newerState) {
1✔
161
            String delayType = determineQueuingDelayType(pickResult);
1✔
162
            String delayReason = determineQueuingDelayReason(pickResult);
1✔
163
            return createPendingStream(args, tracers, pickResult, delayType, delayReason);
1✔
164
          }
165
          state = newerState;
1✔
166
        }
1✔
167
      }
1✔
168
    } finally {
169
      syncContext.drain();
1✔
170
    }
171
  }
172

173
  /**
174
   * Caller must call {@code syncContext.drain()} outside of lock because this method may
175
   * schedule tasks on syncContext.
176
   */
177
  @GuardedBy("lock")
178
  private PendingStream createPendingStream(PickSubchannelArgs args, ClientStreamTracer[] tracers,
179
      PickResult pickResult, @Nullable String delayType, @Nullable String delayReason) {
180
    PendingStream pendingStream = new PendingStream(args, tracers, delayType, delayReason);
1✔
181
    if (args.getCallOptions().isWaitForReady() && pickResult != null && pickResult.hasResult()) {
1✔
182
      pendingStream.lastPickStatus = pickResult.getStatus();
1✔
183
    }
184
    pendingStreams.add(pendingStream);
1✔
185
    if (getPendingStreamsCount() == 1) {
1✔
186
      syncContext.executeLater(reportTransportInUse);
1✔
187
    }
188
    for (ClientStreamTracer streamTracer : tracers) {
1✔
189
      streamTracer.createPendingStream();
1✔
190
    }
191
    return pendingStream;
1✔
192
  }
193

194
  @Override
195
  public final void ping(final PingCallback callback, Executor executor) {
196
    throw new UnsupportedOperationException("This method is not expected to be called");
×
197
  }
198

199
  @Override
200
  public ListenableFuture<SocketStats> getStats() {
201
    SettableFuture<SocketStats> ret = SettableFuture.create();
×
202
    ret.set(null);
×
203
    return ret;
×
204
  }
205

206
  /**
207
   * Prevents creating any new streams. Buffered streams are not failed and may still proceed
208
   * when {@link #reprocess} is called. The delayed transport will be terminated when there is no
209
   * more buffered streams.
210
   */
211
  @Override
212
  public final void shutdown(final Status status) {
213
    synchronized (lock) {
1✔
214
      if (pickerState.shutdownStatus != null) {
1✔
215
        return;
1✔
216
      }
217
      pickerState = pickerState.withShutdownStatus(status);
1✔
218
      syncContext.executeLater(new Runnable() {
1✔
219
          @Override
220
          public void run() {
221
            listener.transportShutdown(status, SimpleDisconnectError.SUBCHANNEL_SHUTDOWN);
1✔
222
          }
1✔
223
        });
224
      if (!hasPendingStreams() && reportTransportTerminated != null) {
1✔
225
        syncContext.executeLater(reportTransportTerminated);
1✔
226
        reportTransportTerminated = null;
1✔
227
      }
228
    }
1✔
229
    syncContext.drain();
1✔
230
  }
1✔
231

232
  /**
233
   * Shuts down this transport and cancels all streams that it owns, hence immediately terminates
234
   * this transport.
235
   */
236
  @Override
237
  public final void shutdownNow(Status status) {
238
    shutdown(status);
1✔
239
    Collection<PendingStream> savedPendingStreams;
240
    Runnable savedReportTransportTerminated;
241
    synchronized (lock) {
1✔
242
      savedPendingStreams = pendingStreams;
1✔
243
      savedReportTransportTerminated = reportTransportTerminated;
1✔
244
      reportTransportTerminated = null;
1✔
245
      if (!pendingStreams.isEmpty()) {
1✔
246
        pendingStreams = Collections.emptyList();
1✔
247
      }
248
    }
1✔
249
    if (savedReportTransportTerminated != null) {
1✔
250
      for (PendingStream stream : savedPendingStreams) {
1✔
251
        Runnable runnable = stream.setStreamAndEndDelay(
1✔
252
            new FailingClientStream(status, RpcProgress.REFUSED, stream.tracers));
1✔
253
        if (runnable != null) {
1✔
254
          // Drain in-line instead of using an executor as failing stream just throws everything
255
          // away. This is essentially the same behavior as DelayedStream.cancel() but can be done
256
          // before stream.start().
257
          runnable.run();
1✔
258
        }
259
      }
1✔
260
      syncContext.execute(savedReportTransportTerminated);
1✔
261
    }
262
    // If savedReportTransportTerminated == null, transportTerminated() has already been called in
263
    // shutdown().
264
  }
1✔
265

266
  public final boolean hasPendingStreams() {
267
    synchronized (lock) {
1✔
268
      return !pendingStreams.isEmpty();
1✔
269
    }
270
  }
271

272
  @VisibleForTesting
273
  final int getPendingStreamsCount() {
274
    synchronized (lock) {
1✔
275
      return pendingStreams.size();
1✔
276
    }
277
  }
278

279
  /**
280
   * Use the picker to try picking a transport for every pending stream, proceed the stream if the
281
   * pick is successful, otherwise keep it pending.
282
   *
283
   * <p>This method may be called concurrently with {@code newStream()}, and it's safe.  All pending
284
   * streams will be served by the latest picker (if a same picker is given more than once, they are
285
   * considered different pickers) as soon as possible.
286
   *
287
   * <p>This method <strong>must not</strong> be called concurrently with itself.
288
   */
289
  final void reprocess(@Nullable SubchannelPicker picker) {
290
    ArrayList<PendingStream> toProcess;
291
    synchronized (lock) {
1✔
292
      pickerState = pickerState.withPicker(picker);
1✔
293
      if (picker == null || !hasPendingStreams()) {
1✔
294
        return;
1✔
295
      }
296
      toProcess = new ArrayList<>(pendingStreams);
1✔
297
    }
1✔
298
    ArrayList<PendingStream> toRemove = new ArrayList<>();
1✔
299

300
    for (final PendingStream stream : toProcess) {
1✔
301
      PickResult pickResult = picker.pickSubchannel(stream.args);
1✔
302
      CallOptions callOptions = stream.args.getCallOptions();
1✔
303
      if (callOptions.isWaitForReady() && pickResult.hasResult()) {
1✔
304
        stream.lastPickStatus = pickResult.getStatus();
1✔
305
      }
306
      final ClientTransport transport = GrpcUtil.getTransportFromPickResult(pickResult,
1✔
307
          callOptions.isWaitForReady());
1✔
308
      if (transport != null) {
1✔
309
        stream.endDelay();
1✔
310
        Executor executor = defaultAppExecutor;
1✔
311
        // createRealStream may be expensive. It will start real streams on the transport. If
312
        // there are pending requests, they will be serialized too, which may be expensive. Since
313
        // we are now on transport thread, we need to offload the work to an executor.
314
        if (callOptions.getExecutor() != null) {
1✔
315
          executor = callOptions.getExecutor();
1✔
316
        }
317
        Runnable runnable = stream.createRealStream(transport, pickResult.getAuthorityOverride());
1✔
318
        if (runnable != null) {
1✔
319
          executor.execute(runnable);
1✔
320
        }
321
        toRemove.add(stream);
1✔
322
      } else { // stay pending
1✔
323
        String delayType = determineQueuingDelayType(pickResult);
1✔
324
        String delayReason = determineQueuingDelayReason(pickResult);
1✔
325
        stream.updateDelay(delayType, delayReason);
1✔
326
      }
327
    }
1✔
328

329
    synchronized (lock) {
1✔
330
      // Between this synchronized and the previous one:
331
      //   - Streams may have been cancelled, which may turn pendingStreams into emptiness.
332
      //   - shutdown() may be called, which may turn pendingStreams into null.
333
      if (!hasPendingStreams()) {
1✔
334
        return;
1✔
335
      }
336
      // Avoid pendingStreams.removeAll() as it can degrade to calling toRemove.contains() for each
337
      // element in pendingStreams.
338
      for (PendingStream stream : toRemove) {
1✔
339
        pendingStreams.remove(stream);
1✔
340
      }
1✔
341
      // Because delayed transport is long-lived, we take this opportunity to down-size the
342
      // hashmap.
343
      if (pendingStreams.isEmpty()) {
1✔
344
        pendingStreams = new LinkedHashSet<>();
1✔
345
      }
346
      if (!hasPendingStreams()) {
1✔
347
        // There may be a brief gap between delayed transport clearing in-use state, and first real
348
        // transport starting streams and setting in-use state.  During the gap the whole channel's
349
        // in-use state may be false. However, it shouldn't cause spurious switching to idleness
350
        // (which would shutdown the transports and LoadBalancer) because the gap should be shorter
351
        // than IDLE_MODE_DEFAULT_TIMEOUT_MILLIS (1 second).
352
        syncContext.executeLater(reportTransportNotInUse);
1✔
353
        if (pickerState.shutdownStatus != null && reportTransportTerminated != null) {
1✔
354
          syncContext.executeLater(reportTransportTerminated);
1✔
355
          reportTransportTerminated = null;
1✔
356
        }
357
      }
358
    }
1✔
359
    syncContext.drain();
1✔
360
  }
1✔
361

362
  @Override
363
  public InternalLogId getLogId() {
364
    return logId;
×
365
  }
366

367
  private static String determineQueuingDelayType(@Nullable PickResult pickResult) {
368
    if (pickResult == null) {
1✔
369
      return "connecting";
1✔
370
    }
371
    if (pickResult.getSubchannel() != null) {
1✔
372
      return "subchannel_state_mismatch";
1✔
373
    }
374
    if (!pickResult.getStatus().isOk()) {
1✔
375
      return "picker_failing_with_wait_for_ready";
1✔
376
    }
377
    if (pickResult.getDelayType() != null) {
1✔
378
      return pickResult.getDelayType();
1✔
379
    }
380
    return "connecting";
1✔
381
  }
382

383
  private static String determineQueuingDelayReason(@Nullable PickResult pickResult) {
384
    if (pickResult == null) {
1✔
385
      return "client channel: waiting for picker";
1✔
386
    }
387
    if (pickResult.getSubchannel() != null) {
1✔
388
      return "subchannel returned by LB picker has no connected subchannel";
1✔
389
    }
390
    if (!pickResult.getStatus().isOk()) {
1✔
391
      return "wait_for_ready RPC failed with status: " + pickResult.getStatus();
1✔
392
    }
393
    if (pickResult.getDelayReason() != null) {
1✔
394
      return pickResult.getDelayReason();
1✔
395
    }
396
    return "client channel: waiting for picker";
1✔
397
  }
398

399
  private class PendingStream extends DelayedStream {
400
    private final PickSubchannelArgs args;
401
    private final Context context = Context.current();
1✔
402
    private final ClientStreamTracer[] tracers;
403
    private volatile Status lastPickStatus;
404
    @GuardedBy("this")
405
    @Nullable private String activeDelayType;
406
    @GuardedBy("this")
407
    @Nullable private String activeDelayReason;
408

409
    private PendingStream(PickSubchannelArgs args, ClientStreamTracer[] tracers,
410
        @Nullable String initialType, @Nullable String initialReason) {
1✔
411
      super("connecting_and_lb");
1✔
412
      this.args = args;
1✔
413
      this.tracers = tracers;
1✔
414
      this.activeDelayType = initialType;
1✔
415
      this.activeDelayReason = initialReason;
1✔
416
      if (initialType != null) {
1✔
417
        for (ClientStreamTracer tracer : tracers) {
1✔
418
          tracer.recordAttemptDelayStart(initialType, initialReason != null ? initialReason : "");
1✔
419
        }
420
      }
421
    }
1✔
422

423
    /**
424
     * Updates active attempt delay telemetry state upon load balancing state transitions.
425
     *
426
     * <p>If {@code newType} differs from the active delay type, active segment timers and child
427
     * spans are ended and a new segment is initiated. If only {@code newReason} changes, a
428
     * structured transition event is appended to the active span without span re-creation.
429
     */
430
    synchronized void updateDelay(@Nullable String newType, @Nullable String newReason) {
431
      if (getRealStream() != null) {
1✔
432
        return;
1✔
433
      }
434
      if (!Objects.equals(activeDelayType, newType)) {
1✔
435
        // Delay type changed (e.g., from RLS lookup to connecting). End the previous delay.
436
        if (activeDelayType != null) {
1✔
437
          for (ClientStreamTracer tracer : tracers) {
1✔
438
            tracer.recordAttemptDelayEnd();
1✔
439
          }
440
        }
441
        activeDelayType = newType;
1✔
442
        activeDelayReason = null;
1✔
443
        if (newType != null) {
1✔
444
          for (ClientStreamTracer tracer : tracers) {
1✔
445
            tracer.recordAttemptDelayStart(newType, newReason != null ? newReason : "");
1✔
446
          }
447
        }
448
      }
449
      if (newType != null && newReason != null && !Objects.equals(activeDelayReason, newReason)) {
1✔
450
        // Delay type is unchanged, but the reason changed (e.g., priority failover).
451
        activeDelayReason = newReason;
1✔
452
        for (ClientStreamTracer tracer : tracers) {
1✔
453
          tracer.recordAttemptDelayReasonChanged(newReason);
1✔
454
        }
455
      }
456
    }
1✔
457

458
    /**
459
     * Ends active attempt delay segment telemetry upon stream creation or stream cancellation.
460
     */
461
    synchronized void endDelay() {
462
      if (activeDelayType != null) {
1✔
463
        for (ClientStreamTracer tracer : tracers) {
1✔
464
          tracer.recordAttemptDelayEnd();
1✔
465
        }
466
        activeDelayType = null;
1✔
467
        activeDelayReason = null;
1✔
468
      }
469
    }
1✔
470

471
    Runnable setStreamAndEndDelay(ClientStream stream) {
472
      endDelay();
1✔
473
      return setStream(stream);
1✔
474
    }
475

476
    /** Runnable may be null. */
477
    private Runnable createRealStream(ClientTransport transport, String authorityOverride) {
478
      ClientStream realStream;
479
      Context origContext = context.attach();
1✔
480
      try {
481
        realStream = transport.newStream(
1✔
482
            args.getMethodDescriptor(), args.getHeaders(), args.getCallOptions(),
1✔
483
            tracers);
484
      } finally {
485
        context.detach(origContext);
1✔
486
      }
487
      if (authorityOverride != null) {
1✔
488
        // User code provided authority takes precedence over the LB provided one; this will be
489
        // overwritten by an enqueud call from ClientCallImpl if the application sets an authority
490
        // override. We must call the real stream directly because stream.start() has likely already
491
        // been called on the delayed stream.
492
        realStream.setAuthority(authorityOverride);
1✔
493
      }
494
      return setStreamAndEndDelay(realStream);
1✔
495
    }
496

497
    @Override
498
    public void cancel(Status reason) {
499
      super.cancel(reason);
1✔
500
      synchronized (lock) {
1✔
501
        if (reportTransportTerminated != null) {
1✔
502
          boolean justRemovedAnElement = pendingStreams.remove(this);
1✔
503
          if (!hasPendingStreams() && justRemovedAnElement) {
1✔
504
            syncContext.executeLater(reportTransportNotInUse);
1✔
505
            if (pickerState.shutdownStatus != null) {
1✔
506
              syncContext.executeLater(reportTransportTerminated);
1✔
507
              reportTransportTerminated = null;
1✔
508
            }
509
          }
510
        }
511
      }
1✔
512
      syncContext.drain();
1✔
513
    }
1✔
514

515
    @Override
516
    protected void onEarlyCancellation(Status reason) {
517
      endDelay();
1✔
518
      for (ClientStreamTracer tracer : tracers) {
1✔
519
        tracer.streamClosed(reason);
1✔
520
      }
521
    }
1✔
522

523
    @Override
524
    public void appendTimeoutInsight(InsightBuilder insight) {
525
      if (args.getCallOptions().isWaitForReady()) {
1✔
526
        insight.append("wait_for_ready");
1✔
527
        Status status = lastPickStatus;
1✔
528
        if (status != null && !status.isOk()) {
1✔
529
          insight.appendKeyValue("Last Pick Failure", status);
1✔
530
        }
531
      }
532
      super.appendTimeoutInsight(insight);
1✔
533
    }
1✔
534
  }
535

536
  static final class PickerState {
537
    /**
538
     * The last picker that {@link #reprocess} has used. May be set to null when the channel has
539
     * moved to idle.
540
     */
541
    @Nullable
542
    final SubchannelPicker lastPicker;
543
    /**
544
     * When {@code shutdownStatus != null && !hasPendingStreams()}, then the transport is considered
545
     * terminated.
546
     */
547
    @Nullable
548
    final Status shutdownStatus;
549

550
    private PickerState(SubchannelPicker lastPicker, Status shutdownStatus) {
1✔
551
      this.lastPicker = lastPicker;
1✔
552
      this.shutdownStatus = shutdownStatus;
1✔
553
    }
1✔
554

555
    public PickerState withPicker(SubchannelPicker newPicker) {
556
      return new PickerState(newPicker, this.shutdownStatus);
1✔
557
    }
558

559
    public PickerState withShutdownStatus(Status newShutdownStatus) {
560
      return new PickerState(this.lastPicker, newShutdownStatus);
1✔
561
    }
562
  }
563
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc