use std::fmt;
use std::str::FromStr;
use is_terminal::IsTerminal;
use crate::{
controllers::{
deployment::{
FetchLogsParams, FetchNetworkFlowLogsParams, fetch_build_logs, fetch_deploy_logs,
fetch_http_logs, fetch_network_flow_logs, stream_build_logs, stream_deploy_logs,
stream_http_logs, stream_network_flow_logs,
},
project::resolve_service_context,
},
util::{
logs::{
LogFormat, format_network_flow_log_header, print_http_log, print_log,
print_network_flow_log,
},
time::parse_time,
},
};
use anyhow::{Context, bail};
use super::{
queries::deployments::{DeploymentListInput, DeploymentStatus},
*,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum HttpMethod {
#[value(name = "GET")]
Get,
#[value(name = "POST")]
Post,
#[value(name = "PUT")]
Put,
#[value(name = "DELETE")]
Delete,
#[value(name = "PATCH")]
Patch,
#[value(name = "HEAD")]
Head,
#[value(name = "OPTIONS")]
Options,
}
impl fmt::Display for HttpMethod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Get => write!(f, "GET"),
Self::Post => write!(f, "POST"),
Self::Put => write!(f, "PUT"),
Self::Delete => write!(f, "DELETE"),
Self::Patch => write!(f, "PATCH"),
Self::Head => write!(f, "HEAD"),
Self::Options => write!(f, "OPTIONS"),
}
}
}
#[derive(Debug, Clone)]
pub enum StatusFilter {
Exact(u16),
Comparison { op: &'static str, value: u16 },
Range { low: u16, high: u16 },
}
impl StatusFilter {
fn to_filter_expr(&self) -> String {
match self {
Self::Exact(v) => format!("@httpStatus:{v}"),
Self::Comparison { op, value } => format!("@httpStatus:{op}{value}"),
Self::Range { low, high } => format!("@httpStatus:{low}..{high}"),
}
}
}
impl FromStr for StatusFilter {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Some((low, high)) = s.split_once("..") {
let low: u16 = low
.parse()
.map_err(|_| format!("Invalid status range start: {low}"))?;
let high: u16 = high
.parse()
.map_err(|_| format!("Invalid status range end: {high}"))?;
if low > high {
return Err(format!("Range start ({low}) must be <= end ({high})"));
}
return Ok(Self::Range { low, high });
}
for prefix in &[">=", "<=", ">", "<"] {
if let Some(rest) = s.strip_prefix(prefix) {
let value: u16 = rest
.parse()
.map_err(|_| format!("Invalid status code: {rest}"))?;
return Ok(Self::Comparison { op: prefix, value });
}
}
let value: u16 = s.parse().map_err(|_| {
format!(
"Invalid status filter: \"{s}\". Expected a number (200), comparison (>=400), or range (500..599)"
)
})?;
Ok(Self::Exact(value))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum NetworkFlowProtocol {
Tcp,
Udp,
Icmp,
Icmpv6,
Unknown,
}
impl fmt::Display for NetworkFlowProtocol {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Tcp => write!(f, "tcp"),
Self::Udp => write!(f, "udp"),
Self::Icmp => write!(f, "icmp"),
Self::Icmpv6 => write!(f, "icmpv6"),
Self::Unknown => write!(f, "unknown"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum NetworkFlowDirection {
Ingress,
Egress,
}
impl fmt::Display for NetworkFlowDirection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Ingress => write!(f, "ingress"),
Self::Egress => write!(f, "egress"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum NetworkFlowPeerKind {
Service,
Internet,
#[value(name = "edge_proxy", alias = "edge-proxy")]
EdgeProxy,
#[value(name = "local_dns", alias = "dns")]
LocalDns,
Unknown,
}
impl fmt::Display for NetworkFlowPeerKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Service => write!(f, "service"),
Self::Internet => write!(f, "internet"),
Self::EdgeProxy => write!(f, "edge_proxy"),
Self::LocalDns => write!(f, "local_dns"),
Self::Unknown => write!(f, "unknown"),
}
}
}
fn build_http_filter(args: &Args) -> Result<Option<String>> {
let status = args
.status
.as_deref()
.map(StatusFilter::from_str)
.transpose()
.map_err(anyhow::Error::msg)?;
Ok(compose_http_filter(
args.method.as_ref(),
status.as_ref(),
args.path.as_deref(),
args.request_id.as_deref(),
args.filter.as_deref(),
))
}
fn build_network_flow_filter(args: &Args) -> Result<Option<String>> {
if args.method.is_some() || args.path.is_some() || args.request_id.is_some() {
bail!("--method, --path, and --request-id can only be used with --http");
}
compose_network_flow_filter(NetworkFlowFilterParts {
protocol: args.protocol.as_ref(),
direction: args.direction.as_ref(),
peer: args.peer.as_deref(),
peer_kind: args.peer_kind.as_ref(),
status: args.status.as_deref(),
dropped: args.dropped,
port: args.port,
src: args.src.as_deref(),
dst: args.dst.as_deref(),
host: args.host.as_deref(),
drop_cause: args.drop_cause.as_deref(),
raw_filter: args.filter.as_deref(),
})
}
fn validate_filter_modes(args: &Args) -> Result<()> {
if args.status.is_some() && !args.http && !args.network {
bail!("--status can only be used with --http or --network");
}
if !args.http && (args.method.is_some() || args.path.is_some() || args.request_id.is_some()) {
bail!("--method, --path, and --request-id can only be used with --http");
}
if !args.network && has_network_flow_filter_args(args) {
bail!(
"--protocol, --direction, --peer, --peer-kind, --dropped, --port, --src, --dst, --host, and --drop-cause can only be used with --network"
);
}
Ok(())
}
fn has_network_flow_filter_args(args: &Args) -> bool {
args.protocol.is_some()
|| args.direction.is_some()
|| args.peer.is_some()
|| args.peer_kind.is_some()
|| args.dropped.is_some()
|| args.port.is_some()
|| args.src.is_some()
|| args.dst.is_some()
|| args.host.is_some()
|| args.drop_cause.is_some()
}
pub struct NetworkFlowFilterParts<'a> {
pub protocol: Option<&'a NetworkFlowProtocol>,
pub direction: Option<&'a NetworkFlowDirection>,
pub peer: Option<&'a str>,
pub peer_kind: Option<&'a NetworkFlowPeerKind>,
pub status: Option<&'a str>,
pub dropped: Option<bool>,
pub port: Option<u16>,
pub src: Option<&'a str>,
pub dst: Option<&'a str>,
pub host: Option<&'a str>,
pub drop_cause: Option<&'a str>,
pub raw_filter: Option<&'a str>,
}
pub fn compose_network_flow_filter(parts: NetworkFlowFilterParts<'_>) -> Result<Option<String>> {
let mut filters = Vec::new();
if let Some(protocol) = parts.protocol {
filters.push(format!("@protocol:{protocol}"));
}
if let Some(direction) = parts.direction {
filters.push(format!("@direction:{direction}"));
}
if let Some(peer) = parts.peer {
filters.push(peer_filter(peer));
}
if let Some(peer_kind) = parts.peer_kind {
filters.push(format!("@peer_kind:{peer_kind}"));
}
if let Some(status) = parts.status {
let status = status.to_ascii_lowercase();
if status != "ok" && status != "dropped" {
bail!("Invalid network flow status: {status}. Expected ok or dropped.");
}
filters.push(format!("@status:{status}"));
}
if let Some(dropped) = parts.dropped {
filters.push(format!("@dropped:{dropped}"));
}
if let Some(port) = parts.port {
filters.push(format!("@port:{port}"));
}
if let Some(src) = parts.src {
filters.push(format!("@src:{src}"));
}
if let Some(dst) = parts.dst {
filters.push(format!("@dst:{dst}"));
}
if let Some(host) = parts.host {
filters.push(format!("@host:{host}"));
}
if let Some(drop_cause) = parts.drop_cause {
filters.push(format!("@drop_cause:{drop_cause}"));
}
if let Some(raw_filter) = parts.raw_filter.filter(|filter| !filter.is_empty()) {
filters.push(raw_filter.to_string());
}
Ok((!filters.is_empty()).then(|| filters.join(" ")))
}
fn peer_filter(peer: &str) -> String {
match peer.to_ascii_lowercase().as_str() {
"internet" => "@peer_kind:internet".to_string(),
"dns" | "local_dns" | "local-dns" => "@peer_kind:local_dns".to_string(),
"edge-proxy" | "edge_proxy" => "@peer_kind:edge_proxy".to_string(),
_ if peer_is_uuid(peer) => format!("@peer:{peer}"),
_ => format!("@peer:{}", serde_json::to_string(peer).unwrap()),
}
}
fn peer_is_uuid(peer: &str) -> bool {
peer.len() == 36 && peer.chars().all(|ch| ch.is_ascii_hexdigit() || ch == '-')
}
fn parse_network_port(value: &str) -> std::result::Result<u16, String> {
let port = value
.parse::<u16>()
.map_err(|_| "port must be a number from 1 to 65535".to_string())?;
if port == 0 {
return Err("port must be a number from 1 to 65535".to_string());
}
Ok(port)
}
pub fn compose_http_filter(
method: Option<&HttpMethod>,
status: Option<&StatusFilter>,
path: Option<&str>,
request_id: Option<&str>,
raw_filter: Option<&str>,
) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
if let Some(method) = method {
parts.push(format!("@method:{method}"));
}
if let Some(status) = status {
parts.push(status.to_filter_expr());
}
if let Some(path) = path {
parts.push(format!("@path:{path}"));
}
if let Some(request_id) = request_id {
parts.push(format!("@requestId:{request_id}"));
}
if let Some(raw_filter) = raw_filter {
if !raw_filter.is_empty() {
parts.push(raw_filter.to_string());
}
}
if parts.is_empty() {
None
} else {
Some(parts.join(" "))
}
}
#[derive(Parser)]
#[clap(
about = "View build, deploy, HTTP, or network flow logs",
long_about = "View build, deploy, HTTP, or network flow logs. This will stream logs by default, or fetch historical logs if the --lines, --since, or --until flags are provided.",
after_help = "Examples:
Deployment logs:
railway logs # Stream live logs from latest deployment
railway logs --build 7422c95b-c604-46bc-9de4-b7a43e1fd53d # Stream build logs from a specific deployment
railway logs --lines 100 # Pull last 100 logs without streaming
railway logs --since 1h # View logs from the last hour
railway logs --since 30m --until 10m # View logs from 30 minutes ago until 10 minutes ago
railway logs --since 2024-01-15T10:00:00Z # View logs since a specific timestamp
railway logs --service backend --environment production # Stream logs from a specific service/environment
railway logs --lines 10 --filter \"@level:error\" # View 10 latest error logs
railway logs --lines 10 --filter \"@level:warn AND rate limit\" # View 10 latest warning logs related to rate limiting
railway logs --json # Get logs in JSON format
railway logs --latest # Stream logs from the latest deployment (even if failed/building)
HTTP logs (typed filters):
railway logs --http --method GET --status 200 # GET requests with 200 status
railway logs --http --method POST --path /api/users # POST requests to /api/users
railway logs --http --status \">=400\" --lines 50 # Client/server errors, last 50
railway logs --http --status 500..599 # Server errors only
railway logs --http --request-id abc123 # Find a specific request
HTTP logs (raw filter for advanced queries):
railway logs --http --method GET --filter \"@totalDuration:>=1000\" # Slow GET requests (combining typed + raw)
railway logs --http --filter \"@srcIp:203.0.113.1 @edgeRegion:us-east-1\" # Filter by source IP and region
railway logs --http --filter \"@httpStatus:>=400 AND @path:/api\" # Errors on API routes
railway logs --http --filter \"-@method:OPTIONS\" # Exclude OPTIONS requests
Network flow logs:
railway logs --network # Stream live network flows
railway logs --network --lines 100 # Pull a snapshot and exit
railway logs --network --direction egress --protocol tcp # Outbound TCP flows
railway logs --network --peer postgres --port 5432 # Flows to a service peer
railway logs --network --status dropped # Dropped flows"
)]
pub struct Args {
#[clap(short, long)]
service: Option<String>,
#[clap(short, long)]
environment: Option<String>,
#[clap(short = 'p', long, value_name = "PROJECT_ID")]
project: Option<String>,
#[clap(short, long, group = "log_type")]
deployment: bool,
#[clap(short, long, group = "log_type")]
build: bool,
#[clap(long, group = "log_type")]
http: bool,
#[clap(long, group = "log_type")]
network: bool,
deployment_id: Option<String>,
#[clap(long)]
json: bool,
#[clap(short = 'n', long = "lines", visible_alias = "tail")]
lines: Option<i64>,
#[clap(
long,
short = 'f',
long_help = "\
Filter logs using Railway's query syntax
For deploy/build logs:
Text search: \"error message\", \"user signup\"
Level filter: @level:error, @level:warn, @level:info
For HTTP logs (--http), all filterable fields:
String: @method, @path, @host, @requestId, @clientUa, @srcIp,
@edgeRegion, @upstreamAddress, @upstreamProto,
@downstreamProto, @responseDetails,
@deploymentId, @deploymentInstanceId
Numeric: @httpStatus, @totalDuration, @responseTime,
@upstreamRqDuration, @txBytes, @rxBytes, @upstreamErrors
For network flow logs (--network), all filterable fields:
String: @protocol, @direction, @peer, @peer_kind, @status,
@drop_cause, @src, @dst, @host
Numeric: @port
Boolean: @dropped
Numeric operators: > >= < <= .. (range, e.g. @httpStatus:200..299)
Logical operators: AND, OR, - (negation), parentheses for grouping
Examples:
@httpStatus:>=400
@totalDuration:>1000
-@method:OPTIONS
@httpStatus:>=400 AND @path:/api
(@method:GET OR @method:POST) AND @httpStatus:500
@protocol:tcp @direction:egress
@peer_kind:internet @port:443"
)]
filter: Option<String>,
#[clap(long, requires = "http", value_enum, ignore_case = true)]
method: Option<HttpMethod>,
#[clap(long, value_name = "STATUS")]
status: Option<String>,
#[clap(long, requires = "http", value_name = "PATH")]
path: Option<String>,
#[clap(long = "request-id", requires = "http", value_name = "ID")]
request_id: Option<String>,
#[clap(long, requires = "network", value_enum, ignore_case = true)]
protocol: Option<NetworkFlowProtocol>,
#[clap(long, requires = "network", value_enum, ignore_case = true)]
direction: Option<NetworkFlowDirection>,
#[clap(long, requires = "network", value_name = "PEER")]
peer: Option<String>,
#[clap(
long = "peer-kind",
requires = "network",
value_enum,
ignore_case = true
)]
peer_kind: Option<NetworkFlowPeerKind>,
#[clap(long, requires = "network", value_name = "BOOL")]
dropped: Option<bool>,
#[clap(long, requires = "network", value_parser = parse_network_port)]
port: Option<u16>,
#[clap(long, requires = "network", value_name = "IP")]
src: Option<String>,
#[clap(long, requires = "network", value_name = "IP")]
dst: Option<String>,
#[clap(long, requires = "network", value_name = "IP")]
host: Option<String>,
#[clap(long = "drop-cause", requires = "network", value_name = "CAUSE")]
drop_cause: Option<String>,
#[clap(long)]
latest: bool,
#[clap(long, short = 'S', value_name = "TIME")]
since: Option<String>,
#[clap(long, short = 'U', value_name = "TIME")]
until: Option<String>,
}
pub async fn command(args: Args) -> Result<()> {
validate_filter_modes(&args)?;
let configs = Configs::new()?;
let client = GQLClient::new_authorized(&configs)?;
let backboard = configs.get_backboard();
let http_filter = if args.http {
build_http_filter(&args)?
} else {
None
};
let network_filter = if args.network {
build_network_flow_filter(&args)?
} else {
None
};
let start_date = args.since.as_ref().map(|s| parse_time(s)).transpose()?;
let end_date = args.until.as_ref().map(|s| parse_time(s)).transpose()?;
if let (Some(s), Some(e)) = (&start_date, &end_date) {
if s >= e {
bail!("--since time must be before --until time");
}
}
let has_time_filter = start_date.is_some() || end_date.is_some();
let should_stream = args.lines.is_none() && !has_time_filter && std::io::stdout().is_terminal();
let ctx = resolve_service_context(args.project, args.service, args.environment).await?;
let project_id = ctx.project_id;
let environment_id = ctx.environment_id;
let service = ctx.service_id;
if args.network {
if args.deployment_id.is_some() || args.latest {
bail!(
"deployment IDs and --latest can only be used with deployment, build, or HTTP logs"
);
}
if !args.json {
println!("{}", format_network_flow_log_header());
}
if should_stream {
stream_network_flow_logs(environment_id, Some(service), network_filter, |log| {
print_network_flow_log(log, args.json)
})
.await?;
} else {
let mut has_logs = false;
fetch_network_flow_logs(
FetchNetworkFlowLogsParams {
client: &client,
backboard: &backboard,
environment_id,
service_id: Some(service),
limit: args.lines.or(Some(500)),
filter: network_filter,
start_date,
end_date,
},
|log| {
has_logs = true;
print_network_flow_log(log, args.json)
},
)
.await?;
if !has_logs && !args.json {
println!("No network flows found");
}
}
return Ok(());
}
let vars = queries::deployments::Variables {
input: DeploymentListInput {
project_id: Some(project_id.clone()),
environment_id: Some(environment_id),
service_id: Some(service),
include_deleted: None,
status: None,
},
first: None,
};
let deployments = post_graphql::<queries::Deployments, _>(&client, &backboard, vars)
.await?
.deployments;
let mut all_deployments: Vec<_> = deployments
.edges
.into_iter()
.map(|deployment| deployment.node)
.collect();
all_deployments.sort_by(|a, b| b.created_at.cmp(&a.created_at));
let default_deployment = if args.latest {
all_deployments.first()
} else {
all_deployments
.iter()
.find(|d| d.status == DeploymentStatus::SUCCESS)
.or_else(|| all_deployments.first())
}
.context("No deployments found")?;
let deployment_id = if let Some(deployment_id) = args.deployment_id {
deployment_id
} else {
default_deployment.id.clone()
};
let show_http_logs = args.http;
let show_build_logs = !show_http_logs
&& !args.deployment
&& (args.build
|| (default_deployment.status == DeploymentStatus::FAILED
&& deployment_id == default_deployment.id));
if show_http_logs {
if should_stream {
stream_http_logs(deployment_id.clone(), http_filter, |log| {
print_http_log(log, args.json)
})
.await?;
} else {
fetch_http_logs(
FetchLogsParams {
client: &client,
backboard: &backboard,
deployment_id: deployment_id.clone(),
limit: args.lines.or(Some(500)),
filter: http_filter,
start_date,
end_date,
},
|log| print_http_log(log, args.json),
)
.await?;
}
} else if show_build_logs {
if should_stream {
stream_build_logs(deployment_id.clone(), args.filter.clone(), |log| {
print_log(log, args.json, LogFormat::LevelOnly)
})
.await?;
} else {
fetch_build_logs(
FetchLogsParams {
client: &client,
backboard: &backboard,
deployment_id: deployment_id.clone(),
limit: args.lines.or(Some(500)),
filter: args.filter.clone(),
start_date,
end_date,
},
|log| print_log(log, args.json, LogFormat::LevelOnly),
)
.await?;
}
} else if should_stream {
stream_deploy_logs(deployment_id.clone(), args.filter.clone(), |log| {
print_log(log, args.json, LogFormat::Full)
})
.await?;
} else {
fetch_deploy_logs(
FetchLogsParams {
client: &client,
backboard: &backboard,
deployment_id: deployment_id.clone(),
limit: args.lines.or(Some(500)),
filter: args.filter.clone(),
start_date,
end_date,
},
|log| print_log(log, args.json, LogFormat::Full),
)
.await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn base_args() -> Args {
Args {
service: None,
environment: None,
project: None,
deployment: false,
build: false,
http: false,
network: false,
deployment_id: None,
json: false,
lines: None,
filter: None,
method: None,
status: None,
path: None,
request_id: None,
protocol: None,
direction: None,
peer: None,
peer_kind: None,
dropped: None,
port: None,
src: None,
dst: None,
host: None,
drop_cause: None,
latest: false,
since: None,
until: None,
}
}
#[test]
fn build_http_filter_composes_typed_and_raw() {
let args = Args::parse_from(["logs", "--http"]);
assert_eq!(build_http_filter(&args).unwrap(), None);
let args = Args::parse_from([
"logs",
"--http",
"--method",
"POST",
"--status",
">=400",
"--path",
"/api/users",
"--request-id",
"abc123",
]);
assert_eq!(
build_http_filter(&args).unwrap(),
Some("@method:POST @httpStatus:>=400 @path:/api/users @requestId:abc123".to_string())
);
let args = Args::parse_from([
"logs",
"--http",
"--method",
"GET",
"--filter",
"@totalDuration:>=1000",
]);
assert_eq!(
build_http_filter(&args).unwrap(),
Some("@method:GET @totalDuration:>=1000".to_string())
);
}
#[test]
fn build_network_flow_filter_composes_typed_and_raw() {
let args = Args::parse_from(["logs", "--network"]);
assert_eq!(build_network_flow_filter(&args).unwrap(), None);
let args = Args::parse_from([
"logs",
"--network",
"--protocol",
"tcp",
"--direction",
"egress",
"--peer",
"postgres",
"--status",
"dropped",
"--port",
"5432",
"--filter",
"@drop_cause:NO_SOCKET",
]);
assert_eq!(
build_network_flow_filter(&args).unwrap(),
Some(
"@protocol:tcp @direction:egress @peer:\"postgres\" @status:dropped @port:5432 @drop_cause:NO_SOCKET"
.to_string()
)
);
}
#[test]
fn network_flow_peer_well_known_names_use_peer_kind() {
let args = Args::parse_from(["logs", "--network", "--peer", "edge-proxy"]);
assert_eq!(
build_network_flow_filter(&args).unwrap(),
Some("@peer_kind:edge_proxy".to_string())
);
}
#[test]
fn network_flow_logs_are_mutually_exclusive_with_other_log_modes() {
assert!(Args::try_parse_from(["logs", "--network", "--http"]).is_err());
assert!(Args::try_parse_from(["logs", "--network", "--build"]).is_err());
assert!(Args::try_parse_from(["logs", "--network", "--deployment"]).is_err());
}
#[test]
fn network_flow_typed_flags_require_network_mode() {
let mut args = base_args();
args.http = true;
args.protocol = Some(NetworkFlowProtocol::Tcp);
assert!(validate_filter_modes(&args).is_err());
let mut args = base_args();
args.network = true;
args.protocol = Some(NetworkFlowProtocol::Tcp);
assert!(validate_filter_modes(&args).is_ok());
}
#[test]
fn http_typed_flags_require_http_mode() {
let mut args = base_args();
args.network = true;
args.method = Some(HttpMethod::Get);
assert!(validate_filter_modes(&args).is_err());
let mut args = base_args();
args.http = true;
args.method = Some(HttpMethod::Get);
assert!(validate_filter_modes(&args).is_ok());
}
#[test]
fn status_requires_http_or_network_mode() {
let mut args = base_args();
args.status = Some("ok".to_string());
assert!(validate_filter_modes(&args).is_err());
let mut args = base_args();
args.network = true;
args.status = Some("ok".to_string());
assert!(validate_filter_modes(&args).is_ok());
}
}