use std::fmt;
use std::str::FromStr;
use is_terminal::IsTerminal;
use crate::{
controllers::{
deployment::{
FetchLogsParams, fetch_build_logs, fetch_deploy_logs, fetch_http_logs,
stream_build_logs, stream_deploy_logs, stream_http_logs,
},
environment::get_matched_environment,
project::{ensure_project_and_environment_exist, get_project},
},
util::{
logs::{LogFormat, print_http_log, print_log},
time::parse_time,
},
};
use anyhow::bail;
use super::{
queries::deployments::{DeploymentListInput, DeploymentStatus},
*,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
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)]
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))
}
}
fn build_http_filter(args: &Args) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
if let Some(ref method) = args.method {
parts.push(format!("@method:{method}"));
}
if let Some(ref status) = args.status {
parts.push(status.to_filter_expr());
}
if let Some(ref path) = args.path {
parts.push(format!("@path:{path}"));
}
if let Some(ref request_id) = args.request_id {
parts.push(format!("@requestId:{request_id}"));
}
if let Some(ref raw_filter) = args.filter {
if !raw_filter.is_empty() {
parts.push(raw_filter.clone());
}
}
if parts.is_empty() {
None
} else {
Some(parts.join(" "))
}
}
#[derive(Parser)]
#[clap(
about = "View build, deploy, or HTTP logs from a Railway deployment",
long_about = "View build, deploy, or HTTP logs from a Railway deployment. 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"
)]
pub struct Args {
#[clap(short, long)]
service: Option<String>,
#[clap(short, long)]
environment: 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,
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
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"
)]
filter: Option<String>,
#[clap(long, requires = "http", value_enum, ignore_case = true)]
method: Option<HttpMethod>,
#[clap(long, requires = "http", value_name = "CODE")]
status: Option<StatusFilter>,
#[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)]
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<()> {
let configs = Configs::new()?;
let client = GQLClient::new_authorized(&configs)?;
let backboard = configs.get_backboard();
let linked_project = configs.get_linked_project().await?;
ensure_project_and_environment_exist(&client, &configs, &linked_project).await?;
let http_filter = build_http_filter(&args);
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 project = get_project(&client, &configs, linked_project.project.clone()).await?;
let environment = match args.environment.clone() {
Some(env) => env,
None => linked_project.environment_id()?.to_string(),
};
let services = project.services.edges.iter().collect::<Vec<_>>();
let environment_id = get_matched_environment(&project, environment)?.id;
let service = match (args.service, linked_project.service) {
(Some(service_arg), _) => services
.iter()
.find(|service| service.node.name == service_arg || service.node.id == service_arg)
.with_context(|| format!("Service '{service_arg}' not found"))?
.node
.id
.to_owned(),
(_, Some(linked_service)) => linked_service,
_ => bail!(
"No service could be found. Please either link one with `railway service` or specify one via the `--service` flag."
),
};
let vars = queries::deployments::Variables {
input: DeploymentListInput {
project_id: Some(linked_project.project.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::*;
#[test]
fn build_http_filter_composes_typed_and_raw() {
let args = Args::parse_from(["logs", "--http"]);
assert_eq!(build_http_filter(&args), None);
let args = Args::parse_from([
"logs",
"--http",
"--method",
"POST",
"--status",
">=400",
"--path",
"/api/users",
"--request-id",
"abc123",
]);
assert_eq!(
build_http_filter(&args),
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),
Some("@method:GET @totalDuration:>=1000".to_string())
);
}
}