blob: 8fbf0163973a9416215f96aa1523a539a05a4f6b [file]
/*!
*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
import * as grpc from '@grpc/grpc-js';
import { ClientOptions, ChannelOptions, ChannelCredentials } from '@grpc/grpc-js';
import config from '../../../config/AgentConfig';
import { createLogger, throttled } from '../../../logging';
import AgentIDDecorator from './AgentIDDecorator';
import AuthenticationDecorator from './AuthenticationDecorator';
import { buildNativeGrpcTarget, parseStaticBackendAddresses } from './BackendAddressResolver';
import GRPCChannel from './GRPCChannel';
import { GRPCChannelListener } from './GRPCChannelListener';
import { GRPCChannelStatus } from './GRPCChannelStatus';
import BootService from '../boot/BootService';
import StandardChannelBuilder from './StandardChannelBuilder';
import TLSChannelBuilder from './TLSChannelBuilder';
const logger = createLogger(__filename);
const logAuthRejected = throttled(logger, 'error', 30000);
const logChannelDisconnected = throttled(logger, 'error', 30000);
const logChannelRecovered = throttled(logger, 'warn', 30000);
/** Placeholder authority — ignored when channelOverride is set (grpc-js client.js). */
const STUB_AUTHORITY = 'skywalking-backend';
function isGrpcAuthError(error: unknown): boolean {
const code = (error as grpc.ServiceError | undefined)?.code;
return code === grpc.status.PERMISSION_DENIED || code === grpc.status.UNAUTHENTICATED;
}
function isGrpcNetworkError(error: unknown): boolean {
const code = (error as grpc.ServiceError | undefined)?.code;
if (isGrpcAuthError(error)) {
return false;
}
return (
code === grpc.status.UNAVAILABLE ||
code === grpc.status.RESOURCE_EXHAUSTED ||
code === grpc.status.UNKNOWN ||
code === grpc.status.DEADLINE_EXCEEDED
);
}
function nativeChannelOptions(): ChannelOptions {
// No gRPC keepalive: stock OAP (grpc-java) rejects frequent idle pings with GOAWAY
// ENHANCE_YOUR_CALM. Agent traffic (trace/heartbeat/metrics) provides liveness.
//
// Agent→OAP via HTTP proxy is not supported. Disable grpc-js proxy interception
// uniformly (including single-address dns: targets that grpc-js could otherwise
// proxy) so host-app http_proxy/https_proxy cannot affect the agent channel —
// multi-address targets are also broken under HTTP CONNECT (proxy sees the
// unresolved comma list).
return {
'grpc.enable_http_proxy': 0,
'grpc.initial_reconnect_backoff_ms': 1_000,
'grpc.max_reconnect_backoff_ms': 30_000,
'grpc.service_config': JSON.stringify({
// shuffleAddressList: distribute agents across backends without rewriting the
// target string (keeps channel authority / SNI stable under TLS).
loadBalancingConfig: [{ pick_first: { shuffleAddressList: true } }],
// Retry only unary idempotent reportInstanceProperties. Client-streaming
// collect (trace/meter) must not retry — grpc-js replays the write buffer and
// OAP does not dedupe segments. keepAlive is covered by the next 20s tick.
// FQDN matches Management.proto (skywalking.v3), not ManagementCompat.
methodConfig: [
{
name: [{ service: 'skywalking.v3.ManagementService', method: 'reportInstanceProperties' }],
retryPolicy: {
maxAttempts: 3,
initialBackoff: '1s',
maxBackoff: '10s',
backoffMultiplier: 2,
retryableStatusCodes: ['UNAVAILABLE'],
},
},
],
}),
};
}
/**
* Shared gRPC channel manager using grpc-js native multi-address failover
* (pick_first + reconnect backoff + service-config retry). Opens one channel at boot.
*/
export default class GRPCChannelManager implements BootService {
private managedChannel: GRPCChannel | null = null;
private readonly listeners: GRPCChannelListener[] = [];
private lastStatus: GRPCChannelStatus | null = null;
private lastConnectivityState: grpc.connectivityState | null = null;
private closed = false;
private grpcServers: string[] = [];
getClientOptions(): ClientOptions {
if (!this.managedChannel) {
throw new Error('gRPC channel is not available');
}
return this.managedChannel.getClientOptions();
}
/**
* Construct a generated gRPC client bound to the shared channel.
* Address/credentials are placeholders — transport uses channelOverride.
*/
createClient<TClient>(
ClientCtor: new (address: string, credentials: ChannelCredentials, options?: ClientOptions) => TClient,
): TClient {
return new ClientCtor(STUB_AUTHORITY, grpc.credentials.createInsecure(), this.getClientOptions());
}
addChannelListener(listener: GRPCChannelListener): void {
this.listeners.push(listener);
if (this.lastStatus !== null) {
listener.statusChanged(this.lastStatus);
}
}
priority(): number {
return Number.MAX_SAFE_INTEGER;
}
/**
* Auth failures: throttled error only (same token across cluster backends).
* Network errors on READY: leave to grpc-js retry / pick_first.
* DEADLINE_EXCEEDED on a READY channel is intentionally not treated as failover —
* failover targets unreachable backends, not slow/overloaded RPCs on a live connection.
*/
reportError(error: unknown): void {
if (this.closed) {
return;
}
if (isGrpcAuthError(error)) {
logAuthRejected('gRPC authentication rejected by OAP; check SW_AGENT_AUTHENTICATION configuration', error);
return;
}
if (!isGrpcNetworkError(error)) {
logger.debug(`gRPC report error (ignored): ${error}`);
return;
}
const managed = this.managedChannel;
if (!managed) {
this.notify(GRPCChannelStatus.DISCONNECT);
return;
}
if (managed.isConnected(false)) {
// Includes DEADLINE_EXCEEDED while READY — do not tear down / rotate (see method doc).
logger.debug(`gRPC network error but channel still READY (native reconnect/retry): ${error}`);
return;
}
logger.debug(`gRPC network error with non-READY channel: ${error}`);
this.notify(GRPCChannelStatus.DISCONNECT);
}
prepare(): void {}
boot(): void {
this.closed = false;
this.lastConnectivityState = null;
const parsed = parseStaticBackendAddresses(config.collectorAddress ?? '');
if (parsed.length === 0) {
logger.error('Collector server addresses are not set.');
logger.error('Agent will not uplink any data.');
this.notify(GRPCChannelStatus.DISCONNECT);
return;
}
// Keep config order in the target so channel authority / SNI stay stable.
// Endpoint pick order is shuffled by pick_first.shuffleAddressList in service_config.
this.grpcServers = [...parsed];
this.openChannel(this.grpcServers);
}
onComplete(): void {}
shutdown(): void {
this.closed = true;
const managed = this.managedChannel;
this.managedChannel = null;
managed?.shutdownNow();
this.notify(GRPCChannelStatus.DISCONNECT);
this.listeners.length = 0;
this.grpcServers = [];
this.lastConnectivityState = null;
}
private openChannel(addresses: string[]): void {
if (this.closed || addresses.length === 0) {
return;
}
let target: string;
try {
target = buildNativeGrpcTarget(addresses);
} catch (error) {
logger.error(`Failed to build gRPC target: ${error}`);
this.notify(GRPCChannelStatus.DISCONNECT);
return;
}
let built: GRPCChannel;
try {
built = GRPCChannel.newBuilder(target)
.withChannelOptions(nativeChannelOptions())
.addManagedChannelBuilder(new StandardChannelBuilder())
.addManagedChannelBuilder(new TLSChannelBuilder())
.addChannelDecorator(new AgentIDDecorator())
.addChannelDecorator(new AuthenticationDecorator())
.build();
} catch (error) {
logger.error(`Failed to build gRPC channel for target [${target}]: ${error}`);
this.notify(GRPCChannelStatus.DISCONNECT);
return;
}
const previous = this.managedChannel;
this.managedChannel = built;
// Defensive: boot() opens once today (ServiceManager.booted), but close any prior
// channel if openChannel is ever invoked again.
previous?.shutdownNow();
this.watchConnectivityState();
// watchConnectivityState already requested a connection; do not request again.
this.notifyCurrentConnectivityState(false);
}
private watchConnectivityState(): void {
const managed = this.managedChannel;
if (this.closed || !managed) {
return;
}
const channel = managed.getChannel();
const currentState = channel.getConnectivityState(true);
channel.watchConnectivityState(currentState, Infinity, (error) => {
if (this.closed || this.managedChannel !== managed) {
return;
}
if (error) {
logger.debug(`Channel connectivity watch stopped: ${error.message}`);
return;
}
this.notifyCurrentConnectivityState(false);
this.watchConnectivityState();
});
}
private notifyCurrentConnectivityState(requestConnection: boolean): void {
const managed = this.managedChannel;
if (this.closed || !managed) {
return;
}
const state = managed.getConnectivityState(requestConnection);
const previousConnectivity = this.lastConnectivityState;
this.lastConnectivityState = state;
if (state === grpc.connectivityState.READY) {
this.notify(GRPCChannelStatus.CONNECTED);
return;
}
// Handshake in progress — do not treat as disconnect.
if (state === grpc.connectivityState.CONNECTING) {
return;
}
// READY→IDLE is grpc-js's normal path after the active connection drops.
if (state === grpc.connectivityState.IDLE) {
if (previousConnectivity === grpc.connectivityState.READY) {
this.notify(GRPCChannelStatus.DISCONNECT);
}
return;
}
this.notify(GRPCChannelStatus.DISCONNECT);
}
private notify(status: GRPCChannelStatus): void {
if (this.lastStatus === status) {
return;
}
const previous = this.lastStatus;
this.lastStatus = status;
this.logStatusTransition(status, previous);
for (const listener of this.listeners) {
try {
listener.statusChanged(status);
} catch (err) {
logger.error(`GRPCChannelListener failed: ${err}`);
}
}
}
/** Error-level connectivity logs; skipped on deliberate shutdown (closed). Throttled. */
private logStatusTransition(status: GRPCChannelStatus, previous: GRPCChannelStatus | null): void {
if (this.closed) {
return;
}
const backends = this.grpcServers.join(',') || config.collectorAddress || '';
if (status === GRPCChannelStatus.DISCONNECT) {
if (previous === GRPCChannelStatus.CONNECTED) {
logChannelDisconnected(
`gRPC channel disconnected from backends [${backends}]; reconnecting with exponential backoff`,
);
} else {
logChannelDisconnected(
`gRPC channel not connected to backends [${backends}]; connecting with exponential backoff`,
);
}
} else if (status === GRPCChannelStatus.CONNECTED && previous === GRPCChannelStatus.DISCONNECT) {
logChannelRecovered(`gRPC channel recovered; connected to backends [${backends}]`);
}
}
}